Clean up files, fix bugs in file manager, update api ports, etc.
This commit is contained in:
@@ -1263,7 +1263,7 @@ app.use(
|
||||
},
|
||||
);
|
||||
|
||||
const HTTP_PORT = 8081;
|
||||
const HTTP_PORT = 30001;
|
||||
const HTTPS_PORT = process.env.SSL_PORT || 8443;
|
||||
|
||||
async function initializeSecurity() {
|
||||
|
||||
@@ -148,8 +148,8 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
backupPath: migrationResult.backupPath,
|
||||
});
|
||||
|
||||
// 🔥 CRITICAL: Migration failure with existing data
|
||||
console.error("🚨 DATABASE MIGRATION FAILED - THIS IS CRITICAL!");
|
||||
// CRITICAL: Migration failure with existing data
|
||||
console.error("DATABASE MIGRATION FAILED - THIS IS CRITICAL!");
|
||||
console.error("Migration error:", migrationResult.error);
|
||||
console.error("Backup available at:", migrationResult.backupPath);
|
||||
console.error("Manual intervention required to recover data.");
|
||||
@@ -177,9 +177,9 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
});
|
||||
|
||||
// 🔥 CRITICAL: Never silently ignore database decryption failures!
|
||||
// CRITICAL: Never silently ignore database decryption failures!
|
||||
// This causes complete data loss for users
|
||||
console.error("🚨 DATABASE DECRYPTION FAILED - THIS IS CRITICAL!");
|
||||
console.error("DATABASE DECRYPTION FAILED - THIS IS CRITICAL!");
|
||||
console.error("Error details:", error instanceof Error ? error.message : error);
|
||||
console.error("Encrypted file exists:", DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath));
|
||||
console.error("DATABASE_KEY available:", !!process.env.DATABASE_KEY);
|
||||
@@ -382,11 +382,6 @@ const addColumnIfNotExists = (
|
||||
.get();
|
||||
} catch (e) {
|
||||
try {
|
||||
databaseLogger.debug(`Adding column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
});
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
databaseLogger.success(`Column ${column} added to ${table}`, {
|
||||
@@ -515,22 +510,9 @@ async function saveMemoryDatabaseToFile() {
|
||||
if (enableFileEncryption) {
|
||||
// Save as encrypted file
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath);
|
||||
|
||||
databaseLogger.debug("In-memory database saved to encrypted file", {
|
||||
operation: "memory_db_save_encrypted",
|
||||
bufferSize: buffer.length,
|
||||
encryptedPath: encryptedDbPath,
|
||||
});
|
||||
} else {
|
||||
// Fallback: save as unencrypted SQLite file to prevent data loss
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
|
||||
databaseLogger.debug("In-memory database saved to unencrypted file", {
|
||||
operation: "memory_db_save_unencrypted",
|
||||
bufferSize: buffer.length,
|
||||
unencryptedPath: dbPath,
|
||||
warning: "File encryption disabled - data saved unencrypted",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
@@ -643,9 +625,6 @@ async function cleanupDatabase() {
|
||||
try {
|
||||
if (sqlite) {
|
||||
sqlite.close();
|
||||
databaseLogger.debug("Database connection closed", {
|
||||
operation: "db_close",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Error closing database connection", {
|
||||
@@ -669,9 +648,6 @@ async function cleanupDatabase() {
|
||||
|
||||
try {
|
||||
fs.rmdirSync(tempDir);
|
||||
databaseLogger.debug("Temp directory cleaned up", {
|
||||
operation: "temp_dir_cleanup",
|
||||
});
|
||||
} catch {
|
||||
// Ignore directory removal errors
|
||||
}
|
||||
@@ -745,12 +721,6 @@ function getMemoryDatabaseBuffer(): Buffer {
|
||||
try {
|
||||
// Export in-memory database to buffer
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
databaseLogger.debug("Memory database serialized to buffer", {
|
||||
operation: "memory_db_serialize",
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
|
||||
@@ -28,8 +28,6 @@ function generateSSHKeyPair(
|
||||
publicKey?: string;
|
||||
error?: string;
|
||||
} {
|
||||
console.log("Generating SSH key pair with ssh2:", keyType);
|
||||
|
||||
try {
|
||||
// Convert our keyType to ssh2 format
|
||||
let ssh2Type = keyType;
|
||||
@@ -54,17 +52,12 @@ function generateSSHKeyPair(
|
||||
// Use ssh2's native key generation
|
||||
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
||||
|
||||
console.log("SSH key pair generated successfully!");
|
||||
console.log("Private key length:", keyPair.private.length);
|
||||
console.log("Public key preview:", keyPair.public.substring(0, 50) + "...");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
privateKey: keyPair.private,
|
||||
publicKey: keyPair.public,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("SSH key generation failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
@@ -785,20 +778,12 @@ router.post(
|
||||
async (req: Request, res: Response) => {
|
||||
const { privateKey, keyPassword } = req.body;
|
||||
|
||||
console.log("=== Key Detection API Called ===");
|
||||
console.log("Request body keys:", Object.keys(req.body));
|
||||
console.log("Private key provided:", !!privateKey);
|
||||
console.log("Private key type:", typeof privateKey);
|
||||
|
||||
if (!privateKey || typeof privateKey !== "string") {
|
||||
console.log("Invalid private key provided");
|
||||
return res.status(400).json({ error: "Private key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Calling parseSSHKey...");
|
||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||
console.log("parseSSHKey result:", keyInfo);
|
||||
|
||||
const response = {
|
||||
success: keyInfo.success,
|
||||
@@ -808,10 +793,8 @@ router.post(
|
||||
error: keyInfo.error || null,
|
||||
};
|
||||
|
||||
console.log("Sending response:", response);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error("Exception in detect-key-type endpoint:", error);
|
||||
authLogger.error("Failed to detect key type", error);
|
||||
res.status(500).json({
|
||||
error:
|
||||
@@ -829,20 +812,12 @@ router.post(
|
||||
async (req: Request, res: Response) => {
|
||||
const { publicKey } = req.body;
|
||||
|
||||
console.log("=== Public Key Detection API Called ===");
|
||||
console.log("Request body keys:", Object.keys(req.body));
|
||||
console.log("Public key provided:", !!publicKey);
|
||||
console.log("Public key type:", typeof publicKey);
|
||||
|
||||
if (!publicKey || typeof publicKey !== "string") {
|
||||
console.log("Invalid public key provided");
|
||||
return res.status(400).json({ error: "Public key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Calling parsePublicKey...");
|
||||
const keyInfo = parsePublicKey(publicKey);
|
||||
console.log("parsePublicKey result:", keyInfo);
|
||||
|
||||
const response = {
|
||||
success: keyInfo.success,
|
||||
@@ -851,10 +826,8 @@ router.post(
|
||||
error: keyInfo.error || null,
|
||||
};
|
||||
|
||||
console.log("Sending response:", response);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error("Exception in detect-public-key-type endpoint:", error);
|
||||
authLogger.error("Failed to detect public key type", error);
|
||||
res.status(500).json({
|
||||
error:
|
||||
@@ -874,29 +847,20 @@ router.post(
|
||||
async (req: Request, res: Response) => {
|
||||
const { privateKey, publicKey, keyPassword } = req.body;
|
||||
|
||||
console.log("=== Key Pair Validation API Called ===");
|
||||
console.log("Request body keys:", Object.keys(req.body));
|
||||
console.log("Private key provided:", !!privateKey);
|
||||
console.log("Public key provided:", !!publicKey);
|
||||
|
||||
if (!privateKey || typeof privateKey !== "string") {
|
||||
console.log("Invalid private key provided");
|
||||
return res.status(400).json({ error: "Private key is required" });
|
||||
}
|
||||
|
||||
if (!publicKey || typeof publicKey !== "string") {
|
||||
console.log("Invalid public key provided");
|
||||
return res.status(400).json({ error: "Public key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Calling validateKeyPair...");
|
||||
const validationResult = validateKeyPair(
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyPassword,
|
||||
);
|
||||
console.log("validateKeyPair result:", validationResult);
|
||||
|
||||
const response = {
|
||||
isValid: validationResult.isValid,
|
||||
@@ -906,10 +870,8 @@ router.post(
|
||||
error: validationResult.error || null,
|
||||
};
|
||||
|
||||
console.log("Sending response:", response);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error("Exception in validate-key-pair endpoint:", error);
|
||||
authLogger.error("Failed to validate key pair", error);
|
||||
res.status(500).json({
|
||||
error:
|
||||
@@ -929,11 +891,6 @@ router.post(
|
||||
async (req: Request, res: Response) => {
|
||||
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
||||
|
||||
console.log("=== Generate Key Pair API Called ===");
|
||||
console.log("Key type:", keyType);
|
||||
console.log("Key size:", keySize);
|
||||
console.log("Has passphrase:", !!passphrase);
|
||||
|
||||
try {
|
||||
// Generate SSH keys directly with ssh2
|
||||
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
||||
@@ -950,17 +907,14 @@ router.post(
|
||||
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
|
||||
};
|
||||
|
||||
console.log("SSH key pair generated successfully:", keyType);
|
||||
res.json(response);
|
||||
} else {
|
||||
console.error("SSH key generation failed:", result.error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: result.error || "Failed to generate SSH key pair",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Exception in generate-key-pair endpoint:", error);
|
||||
authLogger.error("Failed to generate key pair", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
@@ -981,23 +935,11 @@ router.post(
|
||||
async (req: Request, res: Response) => {
|
||||
const { privateKey, keyPassword } = req.body;
|
||||
|
||||
console.log("=== Generate Public Key API Called ===");
|
||||
console.log("Request body keys:", Object.keys(req.body));
|
||||
console.log("Private key provided:", !!privateKey);
|
||||
console.log("Private key type:", typeof privateKey);
|
||||
|
||||
if (!privateKey || typeof privateKey !== "string") {
|
||||
console.log("Invalid private key provided");
|
||||
return res.status(400).json({ error: "Private key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
"Using Node.js crypto to generate public key from private key...",
|
||||
);
|
||||
console.log("Private key length:", privateKey.length);
|
||||
console.log("Private key first 100 chars:", privateKey.substring(0, 100));
|
||||
|
||||
// First try to create private key object from the input
|
||||
let privateKeyObj;
|
||||
let parseAttempts = [];
|
||||
@@ -1008,7 +950,6 @@ router.post(
|
||||
key: privateKey,
|
||||
passphrase: keyPassword,
|
||||
});
|
||||
console.log("Successfully parsed with passphrase method");
|
||||
} catch (error) {
|
||||
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
||||
}
|
||||
@@ -1017,7 +958,6 @@ router.post(
|
||||
if (!privateKeyObj) {
|
||||
try {
|
||||
privateKeyObj = crypto.createPrivateKey(privateKey);
|
||||
console.log("Successfully parsed without passphrase");
|
||||
} catch (error) {
|
||||
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
|
||||
}
|
||||
@@ -1031,7 +971,6 @@ router.post(
|
||||
format: "pem",
|
||||
type: "pkcs8",
|
||||
});
|
||||
console.log("Successfully parsed as PKCS#8");
|
||||
} catch (error) {
|
||||
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
|
||||
}
|
||||
@@ -1048,7 +987,6 @@ router.post(
|
||||
format: "pem",
|
||||
type: "pkcs1",
|
||||
});
|
||||
console.log("Successfully parsed as PKCS#1 RSA");
|
||||
} catch (error) {
|
||||
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
|
||||
}
|
||||
@@ -1065,7 +1003,6 @@ router.post(
|
||||
format: "pem",
|
||||
type: "sec1",
|
||||
});
|
||||
console.log("Successfully parsed as SEC1 EC");
|
||||
} catch (error) {
|
||||
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
|
||||
}
|
||||
@@ -1073,23 +1010,11 @@ router.post(
|
||||
|
||||
// Final attempt: Try using ssh2 as fallback
|
||||
if (!privateKeyObj) {
|
||||
console.log("Attempting fallback to parseSSHKey function...");
|
||||
try {
|
||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||
console.log("parseSSHKey fallback result:", keyInfo);
|
||||
|
||||
if (keyInfo.success && keyInfo.publicKey) {
|
||||
// Ensure SSH2 fallback also returns proper string
|
||||
const publicKeyString = String(keyInfo.publicKey);
|
||||
console.log(
|
||||
"SSH2 fallback public key type:",
|
||||
typeof publicKeyString,
|
||||
);
|
||||
console.log(
|
||||
"SSH2 fallback public key length:",
|
||||
publicKeyString.length,
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
publicKey: publicKeyString,
|
||||
@@ -1106,7 +1031,6 @@ router.post(
|
||||
}
|
||||
|
||||
if (!privateKeyObj) {
|
||||
console.error("All parsing attempts failed:", parseAttempts);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Unable to parse private key. Tried multiple formats.",
|
||||
@@ -1121,30 +1045,12 @@ router.post(
|
||||
format: "pem",
|
||||
});
|
||||
|
||||
// Debug: Check what we're actually generating
|
||||
console.log("Generated public key type:", typeof publicKeyPem);
|
||||
console.log(
|
||||
"Generated public key is Buffer:",
|
||||
Buffer.isBuffer(publicKeyPem),
|
||||
);
|
||||
|
||||
// Ensure publicKeyPem is a string
|
||||
const publicKeyString =
|
||||
typeof publicKeyPem === "string"
|
||||
? publicKeyPem
|
||||
: publicKeyPem.toString("utf8");
|
||||
|
||||
console.log("Public key string length:", publicKeyString.length);
|
||||
console.log(
|
||||
"Generated public key first 100 chars:",
|
||||
publicKeyString.substring(0, 100),
|
||||
);
|
||||
console.log("Public key is string:", typeof publicKeyString === "string");
|
||||
console.log(
|
||||
"Public key contains PEM header:",
|
||||
publicKeyString.includes("-----BEGIN PUBLIC KEY-----"),
|
||||
);
|
||||
|
||||
// Detect key type from the private key object
|
||||
let keyType = "unknown";
|
||||
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
||||
@@ -1169,12 +1075,9 @@ router.post(
|
||||
const base64Data = publicKeyBuffer.toString("base64");
|
||||
finalPublicKey = `${keyType} ${base64Data}`;
|
||||
formatType = "ssh";
|
||||
console.log("SSH format public key generated!");
|
||||
} else {
|
||||
console.warn("ssh2 parsing failed, using PEM format");
|
||||
}
|
||||
} catch (sshError) {
|
||||
console.warn("ssh2 failed, using PEM format");
|
||||
// Use PEM format as fallback
|
||||
}
|
||||
|
||||
const response = {
|
||||
@@ -1184,20 +1087,8 @@ router.post(
|
||||
format: formatType,
|
||||
};
|
||||
|
||||
console.log("Final response publicKey type:", typeof response.publicKey);
|
||||
console.log("Final response publicKey format:", response.format);
|
||||
console.log(
|
||||
"Final response publicKey length:",
|
||||
response.publicKey.length,
|
||||
);
|
||||
console.log(
|
||||
"Public key generated successfully using crypto module:",
|
||||
keyType,
|
||||
);
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error("Exception in generate-public-key endpoint:", error);
|
||||
authLogger.error("Failed to generate public key", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
|
||||
@@ -74,21 +74,6 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
)
|
||||
);
|
||||
|
||||
console.log("=== AUTOSTART QUERY DEBUG ===");
|
||||
console.log("Found autostart hosts count:", autostartHosts.length);
|
||||
autostartHosts.forEach((host, index) => {
|
||||
console.log(`Host ${index + 1}:`, {
|
||||
id: host.id,
|
||||
ip: host.ip,
|
||||
username: host.username,
|
||||
hasAutostartPassword: !!host.autostartPassword,
|
||||
hasAutostartKey: !!host.autostartKey,
|
||||
autostartPasswordLength: host.autostartPassword?.length || 0,
|
||||
autostartKeyLength: host.autostartKey?.length || 0
|
||||
});
|
||||
});
|
||||
console.log("=== END AUTOSTART QUERY DEBUG ===");
|
||||
|
||||
sshLogger.info("Internal autostart endpoint accessed", {
|
||||
operation: "autostart_internal_access",
|
||||
configCount: autostartHosts.length,
|
||||
@@ -102,20 +87,6 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
// Debug: Log what we're reading from database
|
||||
sshLogger.info(`Autostart host from DB:`, {
|
||||
hostId: host.id,
|
||||
ip: host.ip,
|
||||
username: host.username,
|
||||
hasAutostartPassword: !!host.autostartPassword,
|
||||
hasAutostartKey: !!host.autostartKey,
|
||||
hasEncryptedPassword: !!host.password,
|
||||
hasEncryptedKey: !!host.key,
|
||||
authType: host.authType,
|
||||
autostartPasswordLength: host.autostartPassword?.length || 0,
|
||||
autostartKeyLength: host.autostartKey?.length || 0,
|
||||
});
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
@@ -179,22 +150,6 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
// Debug: Log what we're reading from database for all hosts
|
||||
sshLogger.info(`All hosts endpoint - host from DB:`, {
|
||||
hostId: host.id,
|
||||
ip: host.ip,
|
||||
username: host.username,
|
||||
hasAutostartPassword: !!host.autostartPassword,
|
||||
hasAutostartKey: !!host.autostartKey,
|
||||
hasEncryptedPassword: !!host.password,
|
||||
hasEncryptedKey: !!host.key,
|
||||
authType: host.authType,
|
||||
autostartPasswordLength: host.autostartPassword?.length || 0,
|
||||
autostartKeyLength: host.autostartKey?.length || 0,
|
||||
encryptedPasswordLength: host.password?.length || 0,
|
||||
encryptedKeyLength: host.key?.length || 0,
|
||||
});
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
@@ -1474,17 +1429,6 @@ router.post(
|
||||
// Decrypt sensitive fields
|
||||
const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey);
|
||||
|
||||
// Debug: Log what we're about to save
|
||||
console.log("=== AUTOSTART DEBUG: Decrypted credentials ===");
|
||||
console.log("sshConfigId:", sshConfigId);
|
||||
console.log("authType:", config.authType);
|
||||
console.log("hasPassword:", !!decryptedConfig.password);
|
||||
console.log("hasKey:", !!decryptedConfig.key);
|
||||
console.log("hasKeyPassword:", !!decryptedConfig.keyPassword);
|
||||
console.log("passwordLength:", decryptedConfig.password?.length || 0);
|
||||
console.log("keyLength:", decryptedConfig.key?.length || 0);
|
||||
console.log("=== END AUTOSTART DEBUG ===");
|
||||
|
||||
// Also handle tunnel connections - populate endpoint credentials
|
||||
let updatedTunnelConnections = config.tunnelConnections;
|
||||
if (config.tunnelConnections) {
|
||||
@@ -1495,9 +1439,6 @@ router.post(
|
||||
const resolvedConnections = await Promise.all(
|
||||
tunnelConnections.map(async (tunnel: any) => {
|
||||
if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) {
|
||||
console.log("=== RESOLVING ENDPOINT CREDENTIALS ===");
|
||||
console.log("endpointHost:", tunnel.endpointHost);
|
||||
|
||||
// Find endpoint host by name or username@ip
|
||||
const endpointHosts = await db.select()
|
||||
.from(sshData)
|
||||
@@ -1509,17 +1450,9 @@ router.post(
|
||||
);
|
||||
|
||||
if (endpointHost) {
|
||||
console.log("Found endpoint host:", endpointHost.id, endpointHost.ip);
|
||||
|
||||
// Decrypt endpoint host credentials
|
||||
const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey);
|
||||
|
||||
console.log("Endpoint credentials:", {
|
||||
hasPassword: !!decryptedEndpoint.password,
|
||||
hasKey: !!decryptedEndpoint.key,
|
||||
passwordLength: decryptedEndpoint.password?.length || 0
|
||||
});
|
||||
|
||||
// Add endpoint credentials to tunnel connection
|
||||
return {
|
||||
...tunnel,
|
||||
@@ -1535,9 +1468,11 @@ router.post(
|
||||
);
|
||||
|
||||
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
||||
console.log("=== UPDATED TUNNEL CONNECTIONS ===");
|
||||
} catch (error) {
|
||||
console.log("=== TUNNEL CONNECTION UPDATE FAILED ===", error);
|
||||
sshLogger.warn("Failed to update tunnel connections", {
|
||||
operation: "tunnel_connections_update_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1551,36 +1486,14 @@ router.post(
|
||||
})
|
||||
.where(eq(sshData.id, sshConfigId));
|
||||
|
||||
// Debug: Log update result
|
||||
console.log("=== AUTOSTART DEBUG: Update result ===");
|
||||
console.log("updateResult:", updateResult);
|
||||
console.log("update completed for sshConfigId:", sshConfigId);
|
||||
console.log("=== END UPDATE DEBUG ===");
|
||||
|
||||
// Force database save after autostart update
|
||||
try {
|
||||
await DatabaseSaveTrigger.triggerSave();
|
||||
console.log("=== DATABASE SAVE TRIGGERED AFTER AUTOSTART ===");
|
||||
} catch (saveError) {
|
||||
console.log("=== DATABASE SAVE FAILED ===", saveError);
|
||||
}
|
||||
|
||||
// Verify the data was actually saved
|
||||
try {
|
||||
const verifyQuery = await db.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.id, sshConfigId));
|
||||
|
||||
if (verifyQuery.length > 0) {
|
||||
const saved = verifyQuery[0];
|
||||
console.log("=== VERIFICATION: Data actually saved ===");
|
||||
console.log("autostartPassword exists:", !!saved.autostartPassword);
|
||||
console.log("autostartKey exists:", !!saved.autostartKey);
|
||||
console.log("autostartPassword length:", saved.autostartPassword?.length || 0);
|
||||
console.log("=== END VERIFICATION ===");
|
||||
}
|
||||
} catch (verifyError) {
|
||||
console.log("=== VERIFICATION FAILED ===", verifyError);
|
||||
sshLogger.warn("Database save failed after autostart", {
|
||||
operation: "autostart_db_save_failed",
|
||||
error: saveError instanceof Error ? saveError.message : "Unknown error"
|
||||
});
|
||||
}
|
||||
|
||||
sshLogger.success("AutoStart enabled successfully", {
|
||||
|
||||
@@ -505,7 +505,7 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
"http://localhost:5173";
|
||||
|
||||
if (origin.includes("localhost")) {
|
||||
origin = "http://localhost:8081";
|
||||
origin = "http://localhost:30001";
|
||||
}
|
||||
|
||||
const redirectUri = `${origin}/users/oidc/callback`;
|
||||
|
||||
@@ -95,7 +95,6 @@ function scheduleSessionCleanup(sessionId: string) {
|
||||
|
||||
// Increase timeout to 30 minutes of inactivity
|
||||
session.timeout = setTimeout(() => {
|
||||
fileLogger.info(`Cleaning up inactive SSH session: ${sessionId}`);
|
||||
cleanupSession(sessionId);
|
||||
}, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes
|
||||
}
|
||||
@@ -342,12 +341,6 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
fileLogger.debug(`SSH session keepalive: ${sessionId}`, {
|
||||
operation: "ssh_keepalive",
|
||||
sessionId,
|
||||
lastActive: session.lastActive,
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: "success",
|
||||
connected: true,
|
||||
@@ -2124,7 +2117,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = 8084;
|
||||
const PORT = 30004;
|
||||
app.listen(PORT, async () => {
|
||||
fileLogger.success("File Manager API server started", {
|
||||
operation: "server_start",
|
||||
|
||||
@@ -412,19 +412,6 @@ async function resolveHostCredentials(
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
statsLogger.debug(
|
||||
`Using credential ${credential.id} for host ${host.id}`,
|
||||
{
|
||||
operation: "credential_resolve",
|
||||
credentialId: credential.id,
|
||||
authType: credential.authType,
|
||||
hasPassword: !!credential.password,
|
||||
hasKey: !!credential.key,
|
||||
passwordLength: credential.password?.length || 0,
|
||||
keyLength: credential.key?.length || 0,
|
||||
},
|
||||
);
|
||||
|
||||
baseHost.credentialId = credential.id;
|
||||
baseHost.username = credential.username;
|
||||
baseHost.authType = credential.authType;
|
||||
@@ -471,20 +458,6 @@ function addLegacyCredentials(baseHost: any, host: any): void {
|
||||
}
|
||||
|
||||
function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
statsLogger.debug(`Building SSH config for host ${host.ip}`, {
|
||||
operation: "ssh_config",
|
||||
authType: host.authType,
|
||||
hasPassword: !!host.password,
|
||||
hasKey: !!host.key,
|
||||
username: host.username,
|
||||
passwordLength: host.password?.length || 0,
|
||||
keyLength: host.key?.length || 0,
|
||||
passwordType: typeof host.password,
|
||||
passwordRaw: host.password
|
||||
? JSON.stringify(host.password.substring(0, 20))
|
||||
: null,
|
||||
});
|
||||
|
||||
const base: ConnectConfig = {
|
||||
host: host.ip,
|
||||
port: host.port || 22,
|
||||
@@ -521,26 +494,12 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
if (!host.password) {
|
||||
throw new Error(`No password available for host ${host.ip}`);
|
||||
}
|
||||
statsLogger.debug(`Using password auth for ${host.ip}`, {
|
||||
operation: "ssh_config",
|
||||
passwordLength: host.password.length,
|
||||
passwordFirst3: host.password.substring(0, 3),
|
||||
passwordLast3: host.password.substring(host.password.length - 3),
|
||||
passwordType: typeof host.password,
|
||||
passwordIsString: typeof host.password === "string",
|
||||
});
|
||||
(base as any).password = host.password;
|
||||
} else if (host.authType === "key") {
|
||||
if (!host.key) {
|
||||
throw new Error(`No SSH key available for host ${host.ip}`);
|
||||
}
|
||||
|
||||
statsLogger.debug(`Using key auth for ${host.ip}`, {
|
||||
operation: "ssh_config",
|
||||
keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + "...",
|
||||
hasPassphrase: !!host.keyPassword,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
|
||||
throw new Error("Invalid private key format");
|
||||
@@ -988,7 +947,7 @@ process.on("SIGTERM", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
const PORT = 8085;
|
||||
const PORT = 30005;
|
||||
app.listen(PORT, async () => {
|
||||
statsLogger.success("Server Stats API server started", {
|
||||
operation: "server_start",
|
||||
|
||||
@@ -17,7 +17,7 @@ const userCrypto = UserCrypto.getInstance();
|
||||
const userConnections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 8082,
|
||||
port: 30002,
|
||||
// WebSocket authentication during handshake
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
@@ -90,7 +90,7 @@ const wss = new WebSocketServer({
|
||||
|
||||
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
|
||||
operation: "server_start",
|
||||
port: 8082,
|
||||
port: 30002,
|
||||
features: ["JWT_auth", "connection_limits", "data_access_control"]
|
||||
});
|
||||
|
||||
@@ -369,26 +369,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
sshLogger.debug(`Terminal SSH setup`, {
|
||||
operation: "terminal_ssh",
|
||||
hostId: id,
|
||||
ip,
|
||||
authType,
|
||||
hasPassword: !!password,
|
||||
passwordLength: password?.length || 0,
|
||||
hasCredentialId: !!credentialId,
|
||||
});
|
||||
|
||||
// SECURITY: Never log password information - removed password preview logging
|
||||
sshLogger.debug(`SSH authentication setup`, {
|
||||
operation: "terminal_ssh_auth_setup",
|
||||
userId,
|
||||
hostId: id,
|
||||
authType,
|
||||
hasPassword: !!password,
|
||||
hasCredentialId: !!credentialId,
|
||||
});
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
if (credentialId && id && hostConfig.userId) {
|
||||
try {
|
||||
@@ -502,12 +482,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
// Change to initial path if specified
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
sshLogger.debug(`Changing to initial path: ${initialPath}`, {
|
||||
operation: "ssh_initial_path",
|
||||
hostId: id,
|
||||
path: initialPath,
|
||||
});
|
||||
|
||||
// Send cd command to change directory
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||
stream.write(cdCommand);
|
||||
@@ -515,12 +489,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
// Execute command if specified
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
sshLogger.debug(`Executing command: ${executeCommand}`, {
|
||||
operation: "ssh_execute_command",
|
||||
hostId: id,
|
||||
command: executeCommand,
|
||||
});
|
||||
|
||||
// Wait a moment for the cd command to complete, then execute the command
|
||||
setTimeout(() => {
|
||||
const command = `${executeCommand}\n`;
|
||||
|
||||
@@ -1283,7 +1283,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
|
||||
// Get autostart hosts for tunnel configs
|
||||
const autostartResponse = await axios.get(
|
||||
"http://localhost:8081/ssh/db/host/internal",
|
||||
"http://localhost:30001/ssh/db/host/internal",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1294,7 +1294,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
|
||||
// Get all hosts for endpointHost resolution
|
||||
const allHostsResponse = await axios.get(
|
||||
"http://localhost:8081/ssh/db/host/internal/all",
|
||||
"http://localhost:30001/ssh/db/host/internal/all",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1420,7 +1420,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const PORT = 8083;
|
||||
const PORT = 30003;
|
||||
app.listen(PORT, () => {
|
||||
tunnelLogger.success("SSH Tunnel API server started", {
|
||||
operation: "server_start",
|
||||
|
||||
@@ -121,10 +121,6 @@ class AuthManager {
|
||||
migratedFieldsCount: migrationResult.migratedFieldsCount,
|
||||
});
|
||||
} else {
|
||||
databaseLogger.debug("No lazy encryption migration needed for user", {
|
||||
operation: "lazy_encryption_migration_not_needed",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,13 +24,13 @@ export class AutoSSLSetup {
|
||||
*/
|
||||
static async initialize(): Promise<void> {
|
||||
try {
|
||||
systemLogger.info("🔐 Initializing SSL/TLS configuration...", {
|
||||
systemLogger.info("Initializing SSL/TLS configuration...", {
|
||||
operation: "ssl_auto_init"
|
||||
});
|
||||
|
||||
// Check if SSL is already properly configured
|
||||
if (await this.isSSLConfigured()) {
|
||||
systemLogger.info("✅ SSL configuration already exists and is valid", {
|
||||
systemLogger.info("SSL configuration already exists and is valid", {
|
||||
operation: "ssl_already_configured"
|
||||
});
|
||||
return;
|
||||
@@ -42,19 +42,19 @@ export class AutoSSLSetup {
|
||||
// Setup environment variables for SSL
|
||||
await this.setupEnvironmentVariables();
|
||||
|
||||
systemLogger.success("🚀 SSL/TLS configuration completed successfully", {
|
||||
systemLogger.success("SSL/TLS configuration completed successfully", {
|
||||
operation: "ssl_auto_init_complete",
|
||||
https_port: process.env.SSL_PORT || "8443",
|
||||
note: "HTTPS/WSS is now enabled by default"
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
systemLogger.error("❌ Failed to initialize SSL configuration", error, {
|
||||
systemLogger.error("Failed to initialize SSL configuration", error, {
|
||||
operation: "ssl_auto_init_failed"
|
||||
});
|
||||
|
||||
// Don't crash the application - fallback to HTTP
|
||||
systemLogger.warn("⚠️ Falling back to HTTP-only mode", {
|
||||
systemLogger.warn("Falling back to HTTP-only mode", {
|
||||
operation: "ssl_fallback_http"
|
||||
});
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export class AutoSSLSetup {
|
||||
* Generate SSL certificates automatically
|
||||
*/
|
||||
private static async generateSSLCertificates(): Promise<void> {
|
||||
systemLogger.info("🔑 Generating SSL certificates for local development...", {
|
||||
systemLogger.info("Generating SSL certificates for local development...", {
|
||||
operation: "ssl_cert_generation"
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ IP.2 = ::1
|
||||
// Clean up temp config
|
||||
await fs.unlink(configFile);
|
||||
|
||||
systemLogger.success("✅ SSL certificates generated successfully", {
|
||||
systemLogger.success("SSL certificates generated successfully", {
|
||||
operation: "ssl_cert_generated",
|
||||
cert_path: this.CERT_FILE,
|
||||
key_path: this.KEY_FILE,
|
||||
@@ -158,7 +158,7 @@ IP.2 = ::1
|
||||
* Setup environment variables for SSL configuration
|
||||
*/
|
||||
private static async setupEnvironmentVariables(): Promise<void> {
|
||||
systemLogger.info("⚙️ Configuring SSL environment variables...", {
|
||||
systemLogger.info("Configuring SSL environment variables...", {
|
||||
operation: "ssl_env_setup"
|
||||
});
|
||||
|
||||
@@ -207,7 +207,7 @@ IP.2 = ::1
|
||||
if (hasChanges || !envContent) {
|
||||
await fs.writeFile(this.ENV_FILE, updatedContent.trim() + '\n');
|
||||
|
||||
systemLogger.info("✅ SSL environment variables configured", {
|
||||
systemLogger.info("SSL environment variables configured", {
|
||||
operation: "ssl_env_configured",
|
||||
file: this.ENV_FILE,
|
||||
variables: Object.keys(sslEnvVars)
|
||||
@@ -248,12 +248,12 @@ IP.2 = ::1
|
||||
║ HTTP Port: ${(process.env.PORT || "8080").padEnd(47)} ║
|
||||
║ Domain: ${config.domain.padEnd(47)} ║
|
||||
║ ║
|
||||
║ 🌐 Access URLs: ║
|
||||
║ Access URLs: ║
|
||||
║ • HTTPS: https://localhost:${config.port.toString().padEnd(31)} ║
|
||||
║ • HTTP: http://localhost:${(process.env.PORT || "8080").padEnd(32)} ║
|
||||
║ ║
|
||||
║ 🔐 WebSocket connections automatically use WSS over HTTPS ║
|
||||
║ ⚠️ Self-signed certificate will show browser warnings ║
|
||||
║ WebSocket connections automatically use WSS over HTTPS ║
|
||||
║ Self-signed certificate will show browser warnings ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -440,10 +440,6 @@ class DatabaseFileEncryption {
|
||||
for (const tempFile of tempFiles) {
|
||||
if (fs.existsSync(tempFile)) {
|
||||
fs.unlinkSync(tempFile);
|
||||
databaseLogger.debug("Cleaned up temporary file", {
|
||||
operation: "temp_cleanup",
|
||||
file: tempFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -183,12 +183,6 @@ export class DatabaseMigration {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
databaseLogger.debug("Table verification passed", {
|
||||
operation: "migration_verify_table_success",
|
||||
table: table.name,
|
||||
rows: originalCount.count,
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.success("Migration integrity verification completed", {
|
||||
@@ -253,11 +247,6 @@ export class DatabaseMigration {
|
||||
for (const table of tables) {
|
||||
memoryDb.exec(table.sql);
|
||||
migratedTables++;
|
||||
|
||||
databaseLogger.debug("Table structure created", {
|
||||
operation: "migration_table_created",
|
||||
table: table.name,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. 禁用外键约束以避免插入顺序问题
|
||||
@@ -287,12 +276,6 @@ export class DatabaseMigration {
|
||||
|
||||
insertTransaction(rows);
|
||||
migratedRows += rows.length;
|
||||
|
||||
databaseLogger.debug("Table data migrated", {
|
||||
operation: "migration_table_data",
|
||||
table: table.name,
|
||||
rows: rows.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,10 +407,6 @@ export class DatabaseMigration {
|
||||
for (const file of [...backupsToDelete, ...migratedToDelete]) {
|
||||
try {
|
||||
fs.unlinkSync(file.path);
|
||||
databaseLogger.debug("Cleaned up old migration file", {
|
||||
operation: "migration_cleanup",
|
||||
file: file.name,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to cleanup old migration file", {
|
||||
operation: "migration_cleanup_failed",
|
||||
|
||||
@@ -42,27 +42,13 @@ export class DatabaseSaveTrigger {
|
||||
// 防抖:延迟2秒执行,如果2秒内有新的保存请求,则重新计时
|
||||
this.saveTimeout = setTimeout(async () => {
|
||||
if (this.pendingSave) {
|
||||
databaseLogger.debug("Database save already in progress, skipping", {
|
||||
operation: "db_save_trigger_skip",
|
||||
reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingSave = true;
|
||||
|
||||
try {
|
||||
databaseLogger.debug("Triggering database save", {
|
||||
operation: "db_save_trigger_start",
|
||||
reason,
|
||||
});
|
||||
|
||||
await this.saveFunction!();
|
||||
|
||||
databaseLogger.debug("Database save completed", {
|
||||
operation: "db_save_trigger_success",
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database save failed", error, {
|
||||
operation: "db_save_trigger_failed",
|
||||
@@ -94,10 +80,6 @@ export class DatabaseSaveTrigger {
|
||||
}
|
||||
|
||||
if (this.pendingSave) {
|
||||
databaseLogger.debug("Database save already in progress, waiting", {
|
||||
operation: "db_save_trigger_force_wait",
|
||||
reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,22 +41,11 @@ export class LazyFieldEncryption {
|
||||
|
||||
if (this.isPlaintextField(fieldValue)) {
|
||||
// 明文数据,直接返回
|
||||
databaseLogger.debug("Field detected as plaintext, returning as-is", {
|
||||
operation: "lazy_encryption_plaintext_detected",
|
||||
recordId,
|
||||
fieldName,
|
||||
valuePreview: fieldValue.substring(0, 10) + "...",
|
||||
});
|
||||
return fieldValue;
|
||||
} else {
|
||||
// 加密数据,需要解密
|
||||
try {
|
||||
const decrypted = FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
|
||||
databaseLogger.debug("Field decrypted successfully", {
|
||||
operation: "lazy_encryption_decrypt_success",
|
||||
recordId,
|
||||
fieldName,
|
||||
});
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to decrypt field", error, {
|
||||
@@ -108,11 +97,6 @@ export class LazyFieldEncryption {
|
||||
}
|
||||
} else {
|
||||
// 已经加密,无需处理
|
||||
databaseLogger.debug("Field already encrypted, no migration needed", {
|
||||
operation: "lazy_encryption_already_encrypted",
|
||||
recordId,
|
||||
fieldName,
|
||||
});
|
||||
return { encrypted: fieldValue, wasPlaintext: false };
|
||||
}
|
||||
}
|
||||
@@ -149,12 +133,6 @@ export class LazyFieldEncryption {
|
||||
updatedRecord[fieldName] = encrypted;
|
||||
migratedFields.push(fieldName);
|
||||
needsUpdate = true;
|
||||
|
||||
databaseLogger.debug("Record field migrated to encrypted", {
|
||||
operation: "lazy_encryption_record_field_migrated",
|
||||
recordId,
|
||||
fieldName,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to migrate record field", error, {
|
||||
operation: "lazy_encryption_record_field_failed",
|
||||
|
||||
@@ -14,10 +14,23 @@ export interface LogContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Sensitive fields that should be masked in logs
|
||||
const SENSITIVE_FIELDS = [
|
||||
'password', 'passphrase', 'key', 'privateKey', 'publicKey', 'token', 'secret',
|
||||
'clientSecret', 'keyPassword', 'autostartPassword', 'autostartKey', 'autostartKeyPassword',
|
||||
'credentialId', 'authToken', 'jwt', 'session', 'cookie'
|
||||
];
|
||||
|
||||
// Fields that should be truncated if too long
|
||||
const TRUNCATE_FIELDS = ['data', 'content', 'body', 'response', 'request'];
|
||||
|
||||
class Logger {
|
||||
private serviceName: string;
|
||||
private serviceIcon: string;
|
||||
private serviceColor: string;
|
||||
private logCounts = new Map<string, { count: number; lastLog: number }>();
|
||||
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute
|
||||
private readonly RATE_LIMIT_MAX = 10; // Max logs per minute for same message
|
||||
|
||||
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
|
||||
this.serviceName = serviceName;
|
||||
@@ -29,6 +42,32 @@ class Logger {
|
||||
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
}
|
||||
|
||||
private sanitizeContext(context: LogContext): LogContext {
|
||||
const sanitized = { ...context };
|
||||
|
||||
// Mask sensitive fields
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (sanitized[field] !== undefined) {
|
||||
if (typeof sanitized[field] === 'string' && sanitized[field].length > 0) {
|
||||
sanitized[field] = '[MASKED]';
|
||||
} else if (typeof sanitized[field] === 'boolean') {
|
||||
sanitized[field] = sanitized[field] ? '[PRESENT]' : '[ABSENT]';
|
||||
} else {
|
||||
sanitized[field] = '[MASKED]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate long fields
|
||||
for (const field of TRUNCATE_FIELDS) {
|
||||
if (sanitized[field] && typeof sanitized[field] === 'string' && sanitized[field].length > 100) {
|
||||
sanitized[field] = sanitized[field].substring(0, 100) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private formatMessage(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
@@ -41,14 +80,15 @@ class Logger {
|
||||
|
||||
let contextStr = "";
|
||||
if (context) {
|
||||
const sanitizedContext = this.sanitizeContext(context);
|
||||
const contextParts = [];
|
||||
if (context.operation) contextParts.push(`op:${context.operation}`);
|
||||
if (context.userId) contextParts.push(`user:${context.userId}`);
|
||||
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
||||
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
||||
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
||||
if (context.requestId) contextParts.push(`req:${context.requestId}`);
|
||||
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
|
||||
if (sanitizedContext.operation) contextParts.push(`op:${sanitizedContext.operation}`);
|
||||
if (sanitizedContext.userId) contextParts.push(`user:${sanitizedContext.userId}`);
|
||||
if (sanitizedContext.hostId) contextParts.push(`host:${sanitizedContext.hostId}`);
|
||||
if (sanitizedContext.tunnelName) contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
|
||||
if (sanitizedContext.sessionId) contextParts.push(`session:${sanitizedContext.sessionId}`);
|
||||
if (sanitizedContext.requestId) contextParts.push(`req:${sanitizedContext.requestId}`);
|
||||
if (sanitizedContext.duration) contextParts.push(`duration:${sanitizedContext.duration}ms`);
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
|
||||
@@ -75,30 +115,51 @@ class Logger {
|
||||
}
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
private shouldLog(level: LogLevel, message: string): boolean {
|
||||
if (level === "debug" && process.env.NODE_ENV === "production") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rate limiting for frequent messages
|
||||
const now = Date.now();
|
||||
const logKey = `${level}:${message}`;
|
||||
const logInfo = this.logCounts.get(logKey);
|
||||
|
||||
if (logInfo) {
|
||||
if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) {
|
||||
logInfo.count++;
|
||||
if (logInfo.count > this.RATE_LIMIT_MAX) {
|
||||
return false; // Rate limited
|
||||
}
|
||||
} else {
|
||||
// Reset counter for new window
|
||||
logInfo.count = 1;
|
||||
logInfo.lastLog = now;
|
||||
}
|
||||
} else {
|
||||
this.logCounts.set(logKey, { count: 1, lastLog: now });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("debug")) return;
|
||||
if (!this.shouldLog("debug", message)) return;
|
||||
console.debug(this.formatMessage("debug", message, context));
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("info")) return;
|
||||
if (!this.shouldLog("info", message)) return;
|
||||
console.log(this.formatMessage("info", message, context));
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("warn")) return;
|
||||
if (!this.shouldLog("warn", message)) return;
|
||||
console.warn(this.formatMessage("warn", message, context));
|
||||
}
|
||||
|
||||
error(message: string, error?: unknown, context?: LogContext): void {
|
||||
if (!this.shouldLog("error")) return;
|
||||
if (!this.shouldLog("error", message)) return;
|
||||
console.error(this.formatMessage("error", message, context));
|
||||
if (error) {
|
||||
console.error(error);
|
||||
@@ -106,7 +167,7 @@ class Logger {
|
||||
}
|
||||
|
||||
success(message: string, context?: LogContext): void {
|
||||
if (!this.shouldLog("success")) return;
|
||||
if (!this.shouldLog("success", message)) return;
|
||||
console.log(this.formatMessage("success", message, context));
|
||||
}
|
||||
|
||||
|
||||
@@ -53,13 +53,6 @@ class SimpleDBOps {
|
||||
userDataKey
|
||||
);
|
||||
|
||||
databaseLogger.debug(`Inserted encrypted record into ${tableName}`, {
|
||||
operation: "simple_insert",
|
||||
table: tableName,
|
||||
userId,
|
||||
recordId: result[0].id,
|
||||
});
|
||||
|
||||
return decryptedResult as T;
|
||||
}
|
||||
|
||||
@@ -111,13 +104,6 @@ class SimpleDBOps {
|
||||
userDataKey
|
||||
);
|
||||
|
||||
databaseLogger.debug(`Selected single record from ${tableName}`, {
|
||||
operation: "simple_select_one",
|
||||
table: tableName,
|
||||
userId,
|
||||
recordId: result.id,
|
||||
});
|
||||
|
||||
return decryptedResult;
|
||||
}
|
||||
|
||||
@@ -155,13 +141,6 @@ class SimpleDBOps {
|
||||
userDataKey
|
||||
);
|
||||
|
||||
databaseLogger.debug(`Updated records in ${tableName}`, {
|
||||
operation: "simple_update",
|
||||
table: tableName,
|
||||
userId,
|
||||
updatedCount: result.length,
|
||||
});
|
||||
|
||||
return decryptedResults as T[];
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
// Default to RSA for OpenSSH format if we can't detect specifically
|
||||
return "ssh-rsa";
|
||||
} catch (error) {
|
||||
console.warn("Failed to decode OpenSSH key content:", error);
|
||||
// If decoding fails, default to RSA as it's most common for OpenSSH format
|
||||
return "ssh-rsa";
|
||||
}
|
||||
@@ -103,7 +102,6 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
}
|
||||
} catch (error) {
|
||||
// If decoding fails, fall back to length-based detection
|
||||
console.warn("Failed to decode private key for type detection:", error);
|
||||
}
|
||||
|
||||
// Fallback: Try to detect key type from the content structure
|
||||
@@ -176,7 +174,6 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
}
|
||||
} catch (error) {
|
||||
// If decoding fails, fall back to length-based detection
|
||||
console.warn("Failed to decode public key for type detection:", error);
|
||||
}
|
||||
|
||||
// Fallback: Try to guess based on key length
|
||||
@@ -246,15 +243,6 @@ export function parseSSHKey(
|
||||
privateKeyData: string,
|
||||
passphrase?: string,
|
||||
): KeyInfo {
|
||||
console.log("=== SSH Key Parsing Debug ===");
|
||||
console.log("Key length:", privateKeyData?.length || "undefined");
|
||||
console.log(
|
||||
"First 100 chars:",
|
||||
privateKeyData?.substring(0, 100) || "undefined",
|
||||
);
|
||||
console.log("ssh2Utils available:", typeof ssh2Utils);
|
||||
console.log("parseKey function available:", typeof ssh2Utils?.parseKey);
|
||||
|
||||
try {
|
||||
let keyType = "unknown";
|
||||
let publicKey = "";
|
||||
@@ -263,30 +251,17 @@ export function parseSSHKey(
|
||||
// Try SSH2 first if available
|
||||
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
|
||||
try {
|
||||
console.log("Calling ssh2Utils.parseKey...");
|
||||
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
||||
console.log(
|
||||
"parseKey returned:",
|
||||
typeof parsedKey,
|
||||
parsedKey instanceof Error ? parsedKey.message : "success",
|
||||
);
|
||||
|
||||
if (!(parsedKey instanceof Error)) {
|
||||
// Extract key type
|
||||
if (parsedKey.type) {
|
||||
keyType = parsedKey.type;
|
||||
}
|
||||
console.log("Extracted key type:", keyType);
|
||||
|
||||
// Generate public key in SSH format
|
||||
try {
|
||||
console.log("Attempting to generate public key...");
|
||||
const publicKeyBuffer = parsedKey.getPublicSSH();
|
||||
console.log("Public key buffer type:", typeof publicKeyBuffer);
|
||||
console.log(
|
||||
"Public key buffer is Buffer:",
|
||||
Buffer.isBuffer(publicKeyBuffer),
|
||||
);
|
||||
|
||||
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
|
||||
// We need to convert this to proper SSH public key format
|
||||
@@ -304,53 +279,26 @@ export function parseSSHKey(
|
||||
} else {
|
||||
publicKey = `${keyType} ${base64Data}`;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Generated SSH public key format, length:",
|
||||
publicKey.length,
|
||||
);
|
||||
console.log(
|
||||
"Public key starts with:",
|
||||
publicKey.substring(0, 50),
|
||||
);
|
||||
} else {
|
||||
console.warn("Unexpected public key buffer type");
|
||||
publicKey = "";
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to generate public key:", error);
|
||||
publicKey = "";
|
||||
}
|
||||
|
||||
useSSH2 = true;
|
||||
console.log(`SSH key parsed successfully with SSH2: ${keyType}`);
|
||||
} else {
|
||||
console.warn("SSH2 parsing failed:", parsedKey.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"SSH2 parsing exception:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
// SSH2 parsing failed, will fall back to content detection
|
||||
}
|
||||
} else {
|
||||
console.warn("SSH2 parseKey function not available");
|
||||
}
|
||||
|
||||
// Fallback to content-based detection
|
||||
if (!useSSH2) {
|
||||
console.log("Using fallback key type detection...");
|
||||
keyType = detectKeyTypeFromContent(privateKeyData);
|
||||
console.log(`Fallback detected key type: ${keyType}`);
|
||||
|
||||
// For fallback, we can't generate public key but the detection is still useful
|
||||
publicKey = "";
|
||||
|
||||
if (keyType !== "unknown") {
|
||||
console.log(
|
||||
`SSH key type detected successfully with fallback: ${keyType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -360,17 +308,10 @@ export function parseSSHKey(
|
||||
success: keyType !== "unknown",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Exception during SSH key parsing:", error);
|
||||
console.error(
|
||||
"Error stack:",
|
||||
error instanceof Error ? error.stack : "No stack",
|
||||
);
|
||||
|
||||
// Final fallback - try content detection
|
||||
try {
|
||||
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
||||
if (fallbackKeyType !== "unknown") {
|
||||
console.log(`Final fallback detection successful: ${fallbackKeyType}`);
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
publicKey: "",
|
||||
@@ -379,7 +320,7 @@ export function parseSSHKey(
|
||||
};
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error("Even fallback detection failed:", fallbackError);
|
||||
// Even fallback detection failed
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -397,16 +338,8 @@ export function parseSSHKey(
|
||||
* Parse SSH public key and extract type information
|
||||
*/
|
||||
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
||||
console.log("=== SSH Public Key Parsing Debug ===");
|
||||
console.log("Public key length:", publicKeyData?.length || "undefined");
|
||||
console.log(
|
||||
"First 100 chars:",
|
||||
publicKeyData?.substring(0, 100) || "undefined",
|
||||
);
|
||||
|
||||
try {
|
||||
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
||||
console.log(`Public key type detected: ${keyType}`);
|
||||
|
||||
return {
|
||||
publicKey: publicKeyData,
|
||||
@@ -414,7 +347,6 @@ export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
||||
success: keyType !== "unknown",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Exception during SSH public key parsing:", error);
|
||||
return {
|
||||
publicKey: publicKeyData,
|
||||
keyType: "unknown",
|
||||
@@ -469,26 +401,11 @@ export function validateKeyPair(
|
||||
publicKeyData: string,
|
||||
passphrase?: string,
|
||||
): KeyPairValidationResult {
|
||||
console.log("=== Key Pair Validation Debug ===");
|
||||
console.log("Private key length:", privateKeyData?.length || "undefined");
|
||||
console.log("Public key length:", publicKeyData?.length || "undefined");
|
||||
|
||||
try {
|
||||
// First parse the private key and try to generate public key
|
||||
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
||||
const publicKeyInfo = parsePublicKey(publicKeyData);
|
||||
|
||||
console.log(
|
||||
"Private key parsing result:",
|
||||
privateKeyInfo.success,
|
||||
privateKeyInfo.keyType,
|
||||
);
|
||||
console.log(
|
||||
"Public key parsing result:",
|
||||
publicKeyInfo.success,
|
||||
publicKeyInfo.keyType,
|
||||
);
|
||||
|
||||
if (!privateKeyInfo.success) {
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -522,9 +439,6 @@ export function validateKeyPair(
|
||||
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
||||
const providedPublicKey = publicKeyData.trim();
|
||||
|
||||
console.log("Generated public key length:", generatedPublicKey.length);
|
||||
console.log("Provided public key length:", providedPublicKey.length);
|
||||
|
||||
// Compare the key data part (excluding comments)
|
||||
const generatedKeyParts = generatedPublicKey.split(" ");
|
||||
const providedKeyParts = providedPublicKey.split(" ");
|
||||
@@ -535,15 +449,6 @@ export function validateKeyPair(
|
||||
generatedKeyParts[0] + " " + generatedKeyParts[1];
|
||||
const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1];
|
||||
|
||||
console.log(
|
||||
"Generated key data:",
|
||||
generatedKeyData.substring(0, 50) + "...",
|
||||
);
|
||||
console.log(
|
||||
"Provided key data:",
|
||||
providedKeyData.substring(0, 50) + "...",
|
||||
);
|
||||
|
||||
if (generatedKeyData === providedKeyData) {
|
||||
return {
|
||||
isValid: true,
|
||||
@@ -571,7 +476,6 @@ export function validateKeyPair(
|
||||
error: "Unable to verify key pair match, but key types are compatible",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Exception during key pair validation:", error);
|
||||
return {
|
||||
isValid: false,
|
||||
privateKeyType: "unknown",
|
||||
|
||||
@@ -41,10 +41,6 @@ class SystemCrypto {
|
||||
const envSecret = process.env.JWT_SECRET;
|
||||
if (envSecret && envSecret.length >= 64) {
|
||||
this.jwtSecret = envSecret;
|
||||
databaseLogger.info("✅ Using JWT secret from environment variable", {
|
||||
operation: "jwt_env_loaded",
|
||||
source: "environment"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,10 +78,6 @@ class SystemCrypto {
|
||||
const envKey = process.env.DATABASE_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.databaseKey = Buffer.from(envKey, 'hex');
|
||||
databaseLogger.info("✅ Using database key from environment variable", {
|
||||
operation: "db_key_env_loaded",
|
||||
source: "environment"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,10 +115,6 @@ class SystemCrypto {
|
||||
const envToken = process.env.INTERNAL_AUTH_TOKEN;
|
||||
if (envToken && envToken.length >= 32) {
|
||||
this.internalAuthToken = envToken;
|
||||
databaseLogger.info("✅ Using internal auth token from environment variable", {
|
||||
operation: "internal_auth_env_loaded",
|
||||
source: "environment"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -164,7 +152,7 @@ class SystemCrypto {
|
||||
// Auto-save to .env file
|
||||
await this.updateEnvFile("JWT_SECRET", newSecret);
|
||||
|
||||
databaseLogger.success("🔐 JWT secret auto-generated and saved to .env", {
|
||||
databaseLogger.success("JWT secret auto-generated and saved to .env", {
|
||||
operation: "jwt_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "JWT_SECRET",
|
||||
@@ -210,7 +198,7 @@ class SystemCrypto {
|
||||
// Auto-save to .env file
|
||||
await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken);
|
||||
|
||||
databaseLogger.success("🔑 Internal auth token auto-generated and saved to .env", {
|
||||
databaseLogger.success("Internal auth token auto-generated and saved to .env", {
|
||||
operation: "internal_auth_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "INTERNAL_AUTH_TOKEN",
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
"keyPassphraseOptional": "Optional: leave empty if your key has no passphrase",
|
||||
"leaveEmptyToKeepCurrent": "Leave empty to keep current value",
|
||||
"uploadKeyFile": "Upload Key File",
|
||||
"generateKeyPair": "Generate Key Pair",
|
||||
"generateKeyPairButton": "Generate Key Pair",
|
||||
"sshKeyGenerationNotImplemented": "SSH key generation feature coming soon",
|
||||
"connectionTestingNotImplemented": "Connection testing feature coming soon",
|
||||
"testConnection": "Test Connection",
|
||||
@@ -123,7 +123,7 @@
|
||||
"editCredentialDescription": "Update the credential information",
|
||||
"listView": "List",
|
||||
"folderView": "Folders",
|
||||
"unknown": "Unknown",
|
||||
"unknownCredential": "Unknown",
|
||||
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The credential will be moved to \"Uncategorized\".",
|
||||
"removedFromFolder": "Credential \"{{name}}\" removed from folder successfully",
|
||||
"failedToRemoveFromFolder": "Failed to remove credential from folder",
|
||||
@@ -144,7 +144,7 @@
|
||||
"detectedKeyType": "Detected key type",
|
||||
"detectingKeyType": "detecting...",
|
||||
"optional": "Optional",
|
||||
"generateKeyPair": "Generate New Key Pair",
|
||||
"generateKeyPairNew": "Generate New Key Pair",
|
||||
"generateEd25519": "Generate Ed25519",
|
||||
"generateECDSA": "Generate ECDSA",
|
||||
"generateRSA": "Generate RSA",
|
||||
@@ -155,6 +155,16 @@
|
||||
"detectionError": "Detection Error",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"dragIndicator": {
|
||||
"error": "Error: {{error}}",
|
||||
"dragging": "Dragging {{fileName}}",
|
||||
"preparing": "Preparing {{fileName}}",
|
||||
"readySingle": "Ready to download {{fileName}}",
|
||||
"readyMultiple": "Ready to download {{count}} files",
|
||||
"batchDrag": "Drag {{count}} files to desktop",
|
||||
"dragToDesktop": "Drag to desktop",
|
||||
"canDragAnywhere": "You can drag files anywhere on your desktop"
|
||||
},
|
||||
"sshTools": {
|
||||
"title": "SSH Tools",
|
||||
"closeTools": "Close SSH Tools",
|
||||
@@ -190,7 +200,7 @@
|
||||
"saveError": "Error saving configuration",
|
||||
"saving": "Saving...",
|
||||
"saveConfig": "Save Configuration",
|
||||
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:8081 or https://your-server.com)"
|
||||
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)"
|
||||
},
|
||||
"common": {
|
||||
"close": "Close",
|
||||
@@ -294,7 +304,10 @@
|
||||
"failedToInitiatePasswordReset": "Failed to initiate password reset",
|
||||
"failedToVerifyResetCode": "Failed to verify reset code",
|
||||
"failedToCompletePasswordReset": "Failed to complete password reset",
|
||||
"documentation": "Documentation"
|
||||
"documentation": "Documentation",
|
||||
"retry": "Retry",
|
||||
"checking": "Checking...",
|
||||
"checkingDatabase": "Checking database connection..."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
@@ -719,6 +732,9 @@
|
||||
"failedToCreateFile": "Failed to create file",
|
||||
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
|
||||
"failedToCreateFolder": "Failed to create folder",
|
||||
"failedToCreateItem": "Failed to create item",
|
||||
"operationFailed": "{{operation}} operation failed for {{name}}: {{error}}",
|
||||
"failedToResolveSymlink": "Failed to resolve symlink",
|
||||
"itemDeletedSuccessfully": "{{type}} deleted successfully",
|
||||
"itemsDeletedSuccessfully": "{{count}} items deleted successfully",
|
||||
"failedToDeleteItems": "Failed to delete items",
|
||||
@@ -774,7 +790,7 @@
|
||||
"serverError": "Server Error",
|
||||
"error": "Error",
|
||||
"requestFailed": "Request failed with status code",
|
||||
"unknown": "unknown",
|
||||
"unknownFileError": "unknown",
|
||||
"cannotReadFile": "Cannot read file",
|
||||
"noSshSessionId": "No SSH session ID available",
|
||||
"noFilePath": "No file path available",
|
||||
@@ -925,7 +941,7 @@
|
||||
"disconnected": "Disconnected",
|
||||
"connecting": "Connecting...",
|
||||
"disconnecting": "Disconnecting...",
|
||||
"unknown": "Unknown",
|
||||
"unknownTunnelStatus": "Unknown",
|
||||
"error": "Error",
|
||||
"failed": "Failed",
|
||||
"retrying": "Retrying",
|
||||
@@ -962,7 +978,7 @@
|
||||
"dynamic": "Dynamic",
|
||||
"noSshTunnels": "No SSH Tunnels",
|
||||
"createFirstTunnelMessage": "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.",
|
||||
"unknown": "Unknown",
|
||||
"unknownConnectionStatus": "Unknown",
|
||||
"connected": "Connected",
|
||||
"connecting": "Connecting...",
|
||||
"disconnecting": "Disconnecting...",
|
||||
@@ -1105,7 +1121,7 @@
|
||||
"forbidden": "Access forbidden",
|
||||
"serverError": "Server error",
|
||||
"networkError": "Network error",
|
||||
"databaseConnection": "Could not connect to the database. Please try again later.",
|
||||
"databaseConnection": "Could not connect to the database.",
|
||||
"unknownError": "Unknown error",
|
||||
"failedPasswordReset": "Failed to initiate password reset",
|
||||
"failedVerifyCode": "Failed to verify reset code",
|
||||
@@ -1143,7 +1159,15 @@
|
||||
"reconnecting": "Reconnecting...",
|
||||
"processing": "Processing...",
|
||||
"pleaseWait": "Please wait...",
|
||||
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator."
|
||||
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
|
||||
"databaseConnected": "Database connected successfully",
|
||||
"databaseConnectionFailed": "Failed to connect to the database server",
|
||||
"checkServerConnection": "Please check your server connection and try again",
|
||||
"resetCodeSent": "Reset code sent to your email",
|
||||
"codeVerified": "Code verified successfully",
|
||||
"passwordResetSuccess": "Password reset successfully",
|
||||
"loginSuccess": "Login successful",
|
||||
"registrationSuccess": "Registration successful"
|
||||
},
|
||||
"profile": {
|
||||
"title": "User Profile",
|
||||
@@ -1306,6 +1330,9 @@
|
||||
"updateKey": "Update Key",
|
||||
"productionFolder": "Production",
|
||||
"databaseServer": "Database Server",
|
||||
"developmentServer": "Development Server",
|
||||
"developmentFolder": "Development",
|
||||
"webServerProduction": "Web Server - Production",
|
||||
"unknownError": "Unknown error",
|
||||
"failedToInitiatePasswordReset": "Failed to initiate password reset",
|
||||
"failedToVerifyResetCode": "Failed to verify reset code",
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
"keyPassphraseOptional": "可选:如果您的密钥没有密码,请留空",
|
||||
"leaveEmptyToKeepCurrent": "留空以保持当前值",
|
||||
"uploadKeyFile": "上传密钥文件",
|
||||
"generateKeyPair": "生成密钥对",
|
||||
"generateKeyPairButton": "生成密钥对",
|
||||
"sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出",
|
||||
"connectionTestingNotImplemented": "连接测试功能即将推出",
|
||||
"testConnection": "测试连接",
|
||||
@@ -122,7 +122,7 @@
|
||||
"editCredentialDescription": "更新凭据信息",
|
||||
"listView": "列表",
|
||||
"folderView": "文件夹",
|
||||
"unknown": "未知",
|
||||
"unknownCredential": "未知",
|
||||
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?凭据将被移动到\"未分类\"。",
|
||||
"removedFromFolder": "凭据\"{{name}}\"已成功从文件夹中移除",
|
||||
"failedToRemoveFromFolder": "从文件夹中移除凭据失败",
|
||||
@@ -143,7 +143,7 @@
|
||||
"detectedKeyType": "检测到的密钥类型",
|
||||
"detectingKeyType": "检测中...",
|
||||
"optional": "可选",
|
||||
"generateKeyPair": "生成新的密钥对",
|
||||
"generateKeyPairNew": "生成新的密钥对",
|
||||
"generateEd25519": "生成 Ed25519",
|
||||
"generateECDSA": "生成 ECDSA",
|
||||
"generateRSA": "生成 RSA",
|
||||
@@ -151,8 +151,17 @@
|
||||
"failedToGenerateKeyPair": "生成密钥对失败",
|
||||
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。",
|
||||
"invalidKey": "无效密钥",
|
||||
"detectionError": "检测错误",
|
||||
"unknown": "未知"
|
||||
"detectionError": "检测错误"
|
||||
},
|
||||
"dragIndicator": {
|
||||
"error": "错误:{{error}}",
|
||||
"dragging": "正在拖拽 {{fileName}}",
|
||||
"preparing": "正在准备 {{fileName}}",
|
||||
"readySingle": "准备下载 {{fileName}}",
|
||||
"readyMultiple": "准备下载 {{count}} 个文件",
|
||||
"batchDrag": "拖拽 {{count}} 个文件到桌面",
|
||||
"dragToDesktop": "拖拽到桌面",
|
||||
"canDragAnywhere": "您可以将文件拖拽到桌面的任何位置"
|
||||
},
|
||||
"sshTools": {
|
||||
"title": "SSH 工具",
|
||||
@@ -189,7 +198,7 @@
|
||||
"saveError": "保存配置时出错",
|
||||
"saving": "保存中...",
|
||||
"saveConfig": "保存配置",
|
||||
"helpText": "输入您的 Termix 服务器运行地址(例如:http://localhost:8081 或 https://your-server.com)"
|
||||
"helpText": "输入您的 Termix 服务器运行地址(例如:http://localhost:30001 或 https://your-server.com)"
|
||||
},
|
||||
"common": {
|
||||
"close": "关闭",
|
||||
@@ -281,7 +290,10 @@
|
||||
"failedToInitiatePasswordReset": "启动密码重置失败",
|
||||
"failedToVerifyResetCode": "验证重置代码失败",
|
||||
"failedToCompletePasswordReset": "完成密码重置失败",
|
||||
"documentation": "文档"
|
||||
"documentation": "文档",
|
||||
"retry": "重试",
|
||||
"checking": "检查中...",
|
||||
"checkingDatabase": "正在检查数据库连接..."
|
||||
},
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
@@ -742,6 +754,9 @@
|
||||
"failedToCreateFile": "创建文件失败",
|
||||
"folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功",
|
||||
"failedToCreateFolder": "创建文件夹失败",
|
||||
"failedToCreateItem": "创建项目失败",
|
||||
"operationFailed": "{{operation}} 操作失败,文件 {{name}}:{{error}}",
|
||||
"failedToResolveSymlink": "解析符号链接失败",
|
||||
"itemDeletedSuccessfully": "{{type}}删除成功",
|
||||
"itemsDeletedSuccessfully": "{{count}} 个项目删除成功",
|
||||
"failedToDeleteItems": "删除项目失败",
|
||||
@@ -800,7 +815,7 @@
|
||||
"serverError": "服务器错误",
|
||||
"error": "错误",
|
||||
"requestFailed": "请求失败,状态码",
|
||||
"unknown": "未知",
|
||||
"unknownFileError": "未知",
|
||||
"cannotReadFile": "无法读取文件",
|
||||
"noSshSessionId": "没有可用的 SSH 会话 ID",
|
||||
"noFilePath": "没有可用的文件路径",
|
||||
@@ -940,7 +955,7 @@
|
||||
"disconnected": "已断开连接",
|
||||
"connecting": "连接中...",
|
||||
"disconnecting": "断开连接中...",
|
||||
"unknown": "未知",
|
||||
"unknownTunnelStatus": "未知",
|
||||
"error": "错误",
|
||||
"failed": "失败",
|
||||
"retrying": "重试中",
|
||||
@@ -1106,7 +1121,7 @@
|
||||
"forbidden": "访问被禁止",
|
||||
"serverError": "服务器错误",
|
||||
"networkError": "网络错误",
|
||||
"databaseConnection": "无法连接到数据库。请稍后再试。",
|
||||
"databaseConnection": "无法连接到数据库。",
|
||||
"unknownError": "未知错误",
|
||||
"failedPasswordReset": "无法启动密码重置",
|
||||
"failedVerifyCode": "验证重置代码失败",
|
||||
@@ -1144,7 +1159,15 @@
|
||||
"reconnecting": "重新连接中...",
|
||||
"processing": "处理中...",
|
||||
"pleaseWait": "请稍候...",
|
||||
"registrationDisabled": "新用户注册已被管理员禁用。请登录或联系管理员。"
|
||||
"registrationDisabled": "新用户注册已被管理员禁用。请登录或联系管理员。",
|
||||
"databaseConnected": "数据库连接成功",
|
||||
"databaseConnectionFailed": "无法连接到数据库服务器",
|
||||
"checkServerConnection": "请检查您的服务器连接并重试",
|
||||
"resetCodeSent": "重置代码已发送到您的邮箱",
|
||||
"codeVerified": "代码验证成功",
|
||||
"passwordResetSuccess": "密码重置成功",
|
||||
"loginSuccess": "登录成功",
|
||||
"registrationSuccess": "注册成功"
|
||||
},
|
||||
"profile": {
|
||||
"title": "用户资料",
|
||||
@@ -1308,6 +1331,9 @@
|
||||
"sshServerConfigRequired": "需要 SSH 服务器配置",
|
||||
"productionFolder": "生产环境",
|
||||
"databaseServer": "数据库服务器",
|
||||
"developmentServer": "开发服务器",
|
||||
"developmentFolder": "开发环境",
|
||||
"webServerProduction": "Web 服务器 - 生产环境",
|
||||
"unknownError": "未知错误",
|
||||
"failedToInitiatePasswordReset": "启动密码重置失败",
|
||||
"failedToVerifyResetCode": "验证重置代码失败",
|
||||
|
||||
@@ -383,26 +383,6 @@ export interface FileManagerProps {
|
||||
initialHost?: SSHHost | null;
|
||||
}
|
||||
|
||||
export interface FileManagerLeftSidebarProps {
|
||||
onSelectView?: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: Tab[];
|
||||
host: SSHHost;
|
||||
onOperationComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onSuccess?: (message: string) => void;
|
||||
onPathChange?: (path: string) => void;
|
||||
onDeleteItem?: (item: any) => void;
|
||||
}
|
||||
|
||||
export interface FileManagerOperationsProps {
|
||||
currentPath: string;
|
||||
sshSessionId: string | null;
|
||||
onOperationComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onSuccess?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface AlertCardProps {
|
||||
alert: TermixAlert;
|
||||
onDismiss: (alertId: string) => void;
|
||||
|
||||
@@ -295,7 +295,7 @@ export function AdminSettings({
|
||||
const jwt = getCookie("jwt");
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/database/export`
|
||||
: "http://localhost:8081/database/export";
|
||||
: "http://localhost:30001/database/export";
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
@@ -355,7 +355,7 @@ export function AdminSettings({
|
||||
const jwt = getCookie("jwt");
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/database/import`
|
||||
: "http://localhost:8081/database/import";
|
||||
: "http://localhost:30001/database/import";
|
||||
|
||||
// Create FormData for file upload
|
||||
const formData = new FormData();
|
||||
@@ -927,12 +927,29 @@ export function AdminSettings({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.importDescription")}
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground mb-2"
|
||||
/>
|
||||
<div className="relative inline-block w-full mb-2">
|
||||
<input
|
||||
id="import-file-upload"
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={importFile?.name || t("admin.pleaseSelectImportFile")}
|
||||
>
|
||||
{importFile
|
||||
? importFile.name
|
||||
: t("admin.pleaseSelectImportFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-password">Password</Label>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from "react";
|
||||
import { FileManagerTabList } from "./FileManagerTabList.tsx";
|
||||
|
||||
interface FileManagerTopNavbarProps {
|
||||
tabs: { id: string | number; title: string }[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function FIleManagerTopNavbar(
|
||||
props: FileManagerTopNavbarProps,
|
||||
): React.ReactElement {
|
||||
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
|
||||
|
||||
return (
|
||||
<FileManagerTabList
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -257,14 +257,14 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Download function - unified download that uses best available method
|
||||
if (hasFiles && onDragToDesktop) {
|
||||
// Download function - use proper download handler
|
||||
if (hasFiles && onDownload) {
|
||||
menuItems.push({
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.downloadFiles", { count: files.length })
|
||||
: t("fileManager.downloadFile"),
|
||||
action: () => onDragToDesktop(),
|
||||
action: () => onDownload(files),
|
||||
shortcut: "Ctrl+D",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||
import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
interface FileManagerCodeEditorProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
onContentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function FileManagerFileEditor({
|
||||
content,
|
||||
fileName,
|
||||
onContentChange,
|
||||
}: FileManagerCodeEditorProps) {
|
||||
function getLanguageName(filename: string): string {
|
||||
if (!filename || typeof filename !== "string") {
|
||||
return "text";
|
||||
}
|
||||
const lastDotIndex = filename.lastIndexOf(".");
|
||||
if (lastDotIndex === -1) {
|
||||
return "text";
|
||||
}
|
||||
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case "ng":
|
||||
return "angular";
|
||||
case "apl":
|
||||
return "apl";
|
||||
case "asc":
|
||||
return "asciiArmor";
|
||||
case "ast":
|
||||
return "asterisk";
|
||||
case "bf":
|
||||
return "brainfuck";
|
||||
case "c":
|
||||
return "c";
|
||||
case "ceylon":
|
||||
return "ceylon";
|
||||
case "clj":
|
||||
return "clojure";
|
||||
case "cmake":
|
||||
return "cmake";
|
||||
case "cob":
|
||||
case "cbl":
|
||||
return "cobol";
|
||||
case "coffee":
|
||||
return "coffeescript";
|
||||
case "lisp":
|
||||
return "commonLisp";
|
||||
case "cpp":
|
||||
case "cc":
|
||||
case "cxx":
|
||||
return "cpp";
|
||||
case "cr":
|
||||
return "crystal";
|
||||
case "cs":
|
||||
return "csharp";
|
||||
case "css":
|
||||
return "css";
|
||||
case "cypher":
|
||||
return "cypher";
|
||||
case "d":
|
||||
return "d";
|
||||
case "dart":
|
||||
return "dart";
|
||||
case "diff":
|
||||
case "patch":
|
||||
return "diff";
|
||||
case "dockerfile":
|
||||
return "dockerfile";
|
||||
case "dtd":
|
||||
return "dtd";
|
||||
case "dylan":
|
||||
return "dylan";
|
||||
case "ebnf":
|
||||
return "ebnf";
|
||||
case "ecl":
|
||||
return "ecl";
|
||||
case "eiffel":
|
||||
return "eiffel";
|
||||
case "elm":
|
||||
return "elm";
|
||||
case "erl":
|
||||
return "erlang";
|
||||
case "factor":
|
||||
return "factor";
|
||||
case "fcl":
|
||||
return "fcl";
|
||||
case "fs":
|
||||
return "forth";
|
||||
case "f90":
|
||||
case "for":
|
||||
return "fortran";
|
||||
case "s":
|
||||
return "gas";
|
||||
case "feature":
|
||||
return "gherkin";
|
||||
case "go":
|
||||
return "go";
|
||||
case "groovy":
|
||||
return "groovy";
|
||||
case "hs":
|
||||
return "haskell";
|
||||
case "hx":
|
||||
return "haxe";
|
||||
case "html":
|
||||
case "htm":
|
||||
return "html";
|
||||
case "http":
|
||||
return "http";
|
||||
case "idl":
|
||||
return "idl";
|
||||
case "java":
|
||||
return "java";
|
||||
case "js":
|
||||
case "mjs":
|
||||
case "cjs":
|
||||
return "javascript";
|
||||
case "jinja2":
|
||||
case "j2":
|
||||
return "jinja2";
|
||||
case "json":
|
||||
return "json";
|
||||
case "jsx":
|
||||
return "jsx";
|
||||
case "jl":
|
||||
return "julia";
|
||||
case "kt":
|
||||
case "kts":
|
||||
return "kotlin";
|
||||
case "less":
|
||||
return "less";
|
||||
case "lezer":
|
||||
return "lezer";
|
||||
case "liquid":
|
||||
return "liquid";
|
||||
case "litcoffee":
|
||||
return "livescript";
|
||||
case "lua":
|
||||
return "lua";
|
||||
case "md":
|
||||
return "markdown";
|
||||
case "nb":
|
||||
case "mat":
|
||||
return "mathematica";
|
||||
case "mbox":
|
||||
return "mbox";
|
||||
case "mmd":
|
||||
return "mermaid";
|
||||
case "mrc":
|
||||
return "mirc";
|
||||
case "moo":
|
||||
return "modelica";
|
||||
case "mscgen":
|
||||
return "mscgen";
|
||||
case "m":
|
||||
return "mumps";
|
||||
case "sql":
|
||||
return "mysql";
|
||||
case "nc":
|
||||
return "nesC";
|
||||
case "nginx":
|
||||
return "nginx";
|
||||
case "nix":
|
||||
return "nix";
|
||||
case "nsi":
|
||||
return "nsis";
|
||||
case "nt":
|
||||
return "ntriples";
|
||||
case "mm":
|
||||
return "objectiveCpp";
|
||||
case "octave":
|
||||
return "octave";
|
||||
case "oz":
|
||||
return "oz";
|
||||
case "pas":
|
||||
return "pascal";
|
||||
case "pl":
|
||||
case "pm":
|
||||
return "perl";
|
||||
case "pgsql":
|
||||
return "pgsql";
|
||||
case "php":
|
||||
return "php";
|
||||
case "pig":
|
||||
return "pig";
|
||||
case "ps1":
|
||||
return "powershell";
|
||||
case "properties":
|
||||
return "properties";
|
||||
case "proto":
|
||||
return "protobuf";
|
||||
case "pp":
|
||||
return "puppet";
|
||||
case "py":
|
||||
return "python";
|
||||
case "q":
|
||||
return "q";
|
||||
case "r":
|
||||
return "r";
|
||||
case "rb":
|
||||
return "ruby";
|
||||
case "rs":
|
||||
return "rust";
|
||||
case "sas":
|
||||
return "sas";
|
||||
case "sass":
|
||||
case "scss":
|
||||
return "sass";
|
||||
case "scala":
|
||||
return "scala";
|
||||
case "scm":
|
||||
return "scheme";
|
||||
case "shader":
|
||||
return "shader";
|
||||
case "sh":
|
||||
case "bash":
|
||||
return "shell";
|
||||
case "siv":
|
||||
return "sieve";
|
||||
case "st":
|
||||
return "smalltalk";
|
||||
case "sol":
|
||||
return "solidity";
|
||||
case "solr":
|
||||
return "solr";
|
||||
case "rq":
|
||||
return "sparql";
|
||||
case "xlsx":
|
||||
case "ods":
|
||||
case "csv":
|
||||
return "spreadsheet";
|
||||
case "nut":
|
||||
return "squirrel";
|
||||
case "tex":
|
||||
return "stex";
|
||||
case "styl":
|
||||
return "stylus";
|
||||
case "svelte":
|
||||
return "svelte";
|
||||
case "swift":
|
||||
return "swift";
|
||||
case "tcl":
|
||||
return "tcl";
|
||||
case "textile":
|
||||
return "textile";
|
||||
case "tiddlywiki":
|
||||
return "tiddlyWiki";
|
||||
case "tiki":
|
||||
return "tiki";
|
||||
case "toml":
|
||||
return "toml";
|
||||
case "troff":
|
||||
return "troff";
|
||||
case "tsx":
|
||||
return "tsx";
|
||||
case "ttcn":
|
||||
return "ttcn";
|
||||
case "ttl":
|
||||
case "turtle":
|
||||
return "turtle";
|
||||
case "ts":
|
||||
return "typescript";
|
||||
case "vb":
|
||||
return "vb";
|
||||
case "vbs":
|
||||
return "vbscript";
|
||||
case "vm":
|
||||
return "velocity";
|
||||
case "v":
|
||||
return "verilog";
|
||||
case "vhd":
|
||||
case "vhdl":
|
||||
return "vhdl";
|
||||
case "vue":
|
||||
return "vue";
|
||||
case "wat":
|
||||
return "wast";
|
||||
case "webidl":
|
||||
return "webIDL";
|
||||
case "xq":
|
||||
case "xquery":
|
||||
return "xQuery";
|
||||
case "xml":
|
||||
return "xml";
|
||||
case "yacas":
|
||||
return "yacas";
|
||||
case "yaml":
|
||||
case "yml":
|
||||
return "yaml";
|
||||
case "z80":
|
||||
return "z80";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflowX = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflowX = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative overflow-hidden flex flex-col">
|
||||
<div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
|
||||
<CodeMirror
|
||||
value={content}
|
||||
extensions={[
|
||||
loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
|
||||
[],
|
||||
hyperLink,
|
||||
oneDark,
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "var(--color-dark-bg-darkest) !important",
|
||||
height: "100%",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--color-dark-bg) !important",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
},
|
||||
".cm-editor": {
|
||||
height: "100%",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
className="min-h-full min-w-full flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Folder,
|
||||
@@ -60,6 +61,7 @@ function formatFileSize(bytes?: number): string {
|
||||
interface DragState {
|
||||
type: "none" | "internal" | "external";
|
||||
files: FileItem[];
|
||||
draggedFiles?: FileItem[];
|
||||
target?: FileItem;
|
||||
counter: number;
|
||||
mousePosition?: { x: number; y: number };
|
||||
@@ -91,7 +93,7 @@ interface FileManagerGridProps {
|
||||
onFileDrop?: (draggedFiles: FileItem[], targetFile: FileItem) => void;
|
||||
onFileDiff?: (file1: FileItem, file2: FileItem) => void;
|
||||
onSystemDragStart?: (files: FileItem[]) => void;
|
||||
onSystemDragEnd?: (e: DragEvent) => void;
|
||||
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
|
||||
hasClipboard?: boolean;
|
||||
// Linus-style creation intent props
|
||||
createIntent?: CreateIntent | null;
|
||||
@@ -283,6 +285,7 @@ export function FileManagerGrid({
|
||||
setDragState({
|
||||
type: "internal",
|
||||
files: filesToDrag,
|
||||
draggedFiles: filesToDrag,
|
||||
counter: 0,
|
||||
mousePosition: { x: e.clientX, y: e.clientY },
|
||||
});
|
||||
@@ -293,9 +296,6 @@ export function FileManagerGrid({
|
||||
files: filesToDrag.map((f) => f.path),
|
||||
};
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
||||
|
||||
// Trigger system-level drag start
|
||||
onSystemDragStart?.(filesToDrag);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
@@ -378,10 +378,11 @@ export function FileManagerGrid({
|
||||
};
|
||||
|
||||
const handleFileDragEnd = (e: React.DragEvent) => {
|
||||
const draggedFiles = dragState.draggedFiles || [];
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
|
||||
// Trigger system-level drag end detection
|
||||
onSystemDragEnd?.(e.nativeEvent);
|
||||
// Trigger system-level drag end detection with dragged files
|
||||
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
|
||||
};
|
||||
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
@@ -1356,44 +1357,50 @@ export function FileManagerGrid({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag following tooltip */}
|
||||
{/* Drag following tooltip - rendered as portal to ensure highest z-index */}
|
||||
{dragState.type === "internal" &&
|
||||
dragState.files.length > 0 &&
|
||||
dragState.mousePosition && (
|
||||
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
|
||||
dragState.mousePosition &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[99999] pointer-events-none"
|
||||
className="fixed pointer-events-none"
|
||||
style={{
|
||||
left: dragState.mousePosition.x + 24,
|
||||
top: dragState.mousePosition.y - 40,
|
||||
left: Math.min(Math.max(dragState.mousePosition.x + 40, 10), window.innerWidth - 300),
|
||||
top: Math.max(Math.min(dragState.mousePosition.y - 80, window.innerHeight - 100), 10),
|
||||
zIndex: 999999,
|
||||
}}
|
||||
>
|
||||
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
|
||||
{dragState.target ? (
|
||||
dragState.target.type === "directory" ? (
|
||||
<>
|
||||
<Move className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Move to {dragState.target.name}
|
||||
</span>
|
||||
</>
|
||||
{(() => {
|
||||
const files = dragState.files.length > 0 ? dragState.files : dragState.draggedFiles || [];
|
||||
return dragState.target ? (
|
||||
dragState.target.type === "directory" ? (
|
||||
<>
|
||||
<Move className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Move to {dragState.target.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitCompare className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Diff compare with {dragState.target.name}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<GitCompare className="w-4 h-4 text-purple-500" />
|
||||
<Download className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Diff compare with {dragState.target.name}
|
||||
Drag outside window to download ({files.length} files)
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Drag outside window to download ({dragState.files.length} files)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Trash2, Folder, File, Plus, Pin } from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem, ShortcutItem } from "../../../types/index";
|
||||
|
||||
interface FileManagerHomeViewProps {
|
||||
recent: FileItem[];
|
||||
pinned: FileItem[];
|
||||
shortcuts: ShortcutItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onRemoveRecent: (file: FileItem) => void;
|
||||
onPinFile: (file: FileItem) => void;
|
||||
onUnpinFile: (file: FileItem) => void;
|
||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||
onAddShortcut: (path: string) => void;
|
||||
}
|
||||
|
||||
export function FileManagerHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut,
|
||||
}: FileManagerHomeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent");
|
||||
const [newShortcut, setNewShortcut] = useState("");
|
||||
|
||||
const renderFileCard = (
|
||||
file: FileItem,
|
||||
onRemove: () => void,
|
||||
onPin?: () => void,
|
||||
isPinned = false,
|
||||
) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onPin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin
|
||||
className={`w-3 h-3 ${isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||
<div
|
||||
key={shortcut.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{shortcut.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={() => onRemoveShortcut(shortcut)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||
<TabsTrigger
|
||||
value="recent"
|
||||
className="data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
{t("fileManager.recent")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="pinned"
|
||||
className="data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
{t("fileManager.pinned")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="shortcuts"
|
||||
className="data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
{t("fileManager.folderShortcuts")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("fileManager.noRecentFiles")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
recent.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
() => onRemoveRecent(file),
|
||||
() => (file.isPinned ? onUnpinFile(file) : onPinFile(file)),
|
||||
file.isPinned,
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pinned" className="mt-0">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("fileManager.noPinnedFiles")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
pinned.map((file) =>
|
||||
renderFileCard(file, undefined, () => onUnpinFile(file), true),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
|
||||
<Input
|
||||
placeholder={t("fileManager.enterFolderPath")}
|
||||
value={newShortcut}
|
||||
onChange={(e) => setNewShortcut(e.target.value)}
|
||||
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("fileManager.noShortcuts")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
shortcuts.map((shortcut) => renderShortcutCard(shortcut))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,709 +0,0 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import {
|
||||
Folder,
|
||||
File,
|
||||
FileSymlink,
|
||||
ArrowUp,
|
||||
Pin,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
listSSHFiles,
|
||||
renameSSHItem,
|
||||
deleteSSHItem,
|
||||
getFileManagerPinned,
|
||||
addFileManagerPinned,
|
||||
removeFileManagerPinned,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
identifySSHSymlink,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { SSHHost } from "../../../types/index.js";
|
||||
|
||||
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
{
|
||||
onOpenFile,
|
||||
tabs,
|
||||
host,
|
||||
onOperationComplete,
|
||||
onPathChange,
|
||||
onDeleteItem,
|
||||
}: {
|
||||
onSelectView?: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: any[];
|
||||
host: SSHHost;
|
||||
onOperationComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onSuccess?: (message: string) => void;
|
||||
onPathChange?: (path: string) => void;
|
||||
onDeleteItem?: (item: any) => void;
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [fileSearch, setFileSearch] = useState("");
|
||||
const [debouncedFileSearch, setDebouncedFileSearch] = useState("");
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [fileSearch]);
|
||||
|
||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
||||
const [connectionCache, setConnectionCache] = useState<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
sessionId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
>
|
||||
>({});
|
||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
item: any;
|
||||
}>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
item: null,
|
||||
});
|
||||
|
||||
const [renamingItem, setRenamingItem] = useState<{
|
||||
item: any;
|
||||
newName: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextPath = host?.defaultPath || "/";
|
||||
setCurrentPath(nextPath);
|
||||
onPathChange?.(nextPath);
|
||||
(async () => {
|
||||
await connectToSSH(host);
|
||||
})();
|
||||
}, [host?.id]);
|
||||
|
||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
||||
const sessionId = server.id.toString();
|
||||
|
||||
const cached = connectionCache[sessionId];
|
||||
if (cached && Date.now() - cached.timestamp < 30000) {
|
||||
setSshSessionId(cached.sessionId);
|
||||
return cached.sessionId;
|
||||
}
|
||||
|
||||
if (connectingSSH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setConnectingSSH(true);
|
||||
|
||||
try {
|
||||
if (!server.password && !server.key) {
|
||||
toast.error(t("common.noAuthCredentials"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionConfig = {
|
||||
hostId: server.id,
|
||||
ip: server.ip,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
sshKey: server.key,
|
||||
keyPassword: server.keyPassword,
|
||||
authType: server.authType,
|
||||
credentialId: server.credentialId,
|
||||
userId: server.userId,
|
||||
};
|
||||
|
||||
await connectSSH(sessionId, connectionConfig);
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
setConnectionCache((prev) => ({
|
||||
...prev,
|
||||
[sessionId]: { sessionId, timestamp: Date.now() },
|
||||
}));
|
||||
|
||||
return sessionId;
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
err?.response?.data?.error || t("fileManager.failedToConnectSSH"),
|
||||
);
|
||||
setSshSessionId(null);
|
||||
return null;
|
||||
} finally {
|
||||
setConnectingSSH(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
if (fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(true);
|
||||
setFiles([]);
|
||||
setFilesLoading(true);
|
||||
|
||||
try {
|
||||
let pinnedFiles: any[] = [];
|
||||
try {
|
||||
if (host) {
|
||||
pinnedFiles = await getFileManagerPinned(host.id);
|
||||
}
|
||||
} catch (err) {}
|
||||
|
||||
if (host && sshSessionId) {
|
||||
let res: any[] = [];
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
if (!status.connected) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw new Error(t("fileManager.failedToReconnectSSH"));
|
||||
}
|
||||
} else {
|
||||
res = await listSSHFiles(sshSessionId, currentPath);
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw sessionErr;
|
||||
}
|
||||
}
|
||||
|
||||
const processedFiles = (res || []).map((f: any) => {
|
||||
const filePath =
|
||||
currentPath + (currentPath.endsWith("/") ? "" : "/") + f.name;
|
||||
const isPinned = pinnedFiles.some(
|
||||
(pinned) => pinned.path === filePath,
|
||||
);
|
||||
return {
|
||||
...f,
|
||||
path: filePath,
|
||||
isPinned,
|
||||
isSSH: true,
|
||||
sshSessionId: sshSessionId,
|
||||
};
|
||||
});
|
||||
|
||||
setFiles(processedFiles);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFiles([]);
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
t("fileManager.failedToListFiles"),
|
||||
);
|
||||
} finally {
|
||||
setFilesLoading(false);
|
||||
setFetchingFiles(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchFiles();
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [currentPath, host, sshSessionId]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openFolder: async (_server: SSHHost, path: string) => {
|
||||
if (connectingSSH || fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPath === path) {
|
||||
setTimeout(() => fetchFiles(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(false);
|
||||
setFilesLoading(false);
|
||||
setFiles([]);
|
||||
|
||||
setCurrentPath(path);
|
||||
onPathChange?.(path);
|
||||
if (!sshSessionId) {
|
||||
const sessionId = await connectToSSH(host);
|
||||
if (sessionId) setSshSessionId(sessionId);
|
||||
}
|
||||
},
|
||||
fetchFiles: () => {
|
||||
if (host && sshSessionId) {
|
||||
fetchFiles();
|
||||
}
|
||||
},
|
||||
getCurrentPath: () => currentPath,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (pathInputRef.current) {
|
||||
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
const filteredFiles = files.filter((file) => {
|
||||
const q = debouncedFileSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return file.name.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, item: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const menuWidth = 160;
|
||||
const menuHeight = 80;
|
||||
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
|
||||
if (x + menuWidth > viewportWidth) {
|
||||
x = e.clientX - menuWidth;
|
||||
}
|
||||
|
||||
if (y + menuHeight > viewportHeight) {
|
||||
y = e.clientY - menuHeight;
|
||||
}
|
||||
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
item,
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu({ visible: false, x: 0, y: 0, item: null });
|
||||
};
|
||||
|
||||
const handleRename = async (item: any, newName: string) => {
|
||||
if (!sshSessionId || !newName.trim() || newName === item.name) {
|
||||
setRenamingItem(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await renameSSHItem(sshSessionId, item.path, newName.trim());
|
||||
toast.success(
|
||||
`${item.type === "directory" ? t("common.folder") : item.type === "link" ? t("common.link") : t("common.file")} ${t("common.renamedSuccessfully")}`,
|
||||
);
|
||||
setRenamingItem(null);
|
||||
if (onOperationComplete) {
|
||||
onOperationComplete();
|
||||
} else {
|
||||
fetchFiles();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (item: any) => {
|
||||
setRenamingItem({ item, newName: item.name });
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const startDelete = (item: any) => {
|
||||
onDeleteItem?.(item);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => closeContextMenu();
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handlePathChange = (newPath: string) => {
|
||||
setCurrentPath(newPath);
|
||||
onPathChange?.(newPath);
|
||||
};
|
||||
|
||||
// Handle symlink resolution
|
||||
const handleSymlinkClick = async (item: any) => {
|
||||
if (!host) return;
|
||||
|
||||
try {
|
||||
// Extract just the symlink path (before the " -> " if present)
|
||||
const symlinkPath = item.path.includes(" -> ")
|
||||
? item.path.split(" -> ")[0]
|
||||
: item.path;
|
||||
|
||||
let currentSessionId = sshSessionId;
|
||||
|
||||
// Check SSH connection status and reconnect if needed
|
||||
if (currentSessionId) {
|
||||
try {
|
||||
const status = await getSSHStatus(currentSessionId);
|
||||
if (!status.connected) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
currentSessionId = newSessionId;
|
||||
} else {
|
||||
throw new Error(t("fileManager.failedToReconnectSSH"));
|
||||
}
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
currentSessionId = newSessionId;
|
||||
} else {
|
||||
throw sessionErr;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No session ID, try to connect
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
currentSessionId = newSessionId;
|
||||
} else {
|
||||
throw new Error(t("fileManager.failedToConnectSSH"));
|
||||
}
|
||||
}
|
||||
|
||||
const symlinkInfo = await identifySSHSymlink(
|
||||
currentSessionId,
|
||||
symlinkPath,
|
||||
);
|
||||
|
||||
if (symlinkInfo.type === "directory") {
|
||||
// If symlink points to a directory, navigate to it
|
||||
handlePathChange(symlinkInfo.target);
|
||||
} else if (symlinkInfo.type === "file") {
|
||||
// If symlink points to a file, open it as a file
|
||||
onOpenFile({
|
||||
name: item.name,
|
||||
path: symlinkInfo.target, // Use the target path, not the symlink path
|
||||
isSSH: item.isSSH,
|
||||
sshSessionId: currentSessionId,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
t("fileManager.failedToResolveSymlink"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
|
||||
<div className="flex flex-col flex-grow min-h-0">
|
||||
<div className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
|
||||
{host && (
|
||||
<div className="flex flex-col h-full w-full max-w-[260px]">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-9 w-9 bg-dark-bg border-2 border-dark-border rounded-md hover:bg-dark-hover focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onClick={() => {
|
||||
let path = currentPath;
|
||||
if (path && path !== "/" && path !== "") {
|
||||
if (path.endsWith("/")) path = path.slice(0, -1);
|
||||
const lastSlash = path.lastIndexOf("/");
|
||||
if (lastSlash > 0) {
|
||||
handlePathChange(path.slice(0, lastSlash));
|
||||
} else {
|
||||
handlePathChange("/");
|
||||
}
|
||||
} else {
|
||||
handlePathChange("/");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={currentPath}
|
||||
onChange={(e) => handlePathChange(e.target.value)}
|
||||
className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-2 border-b-1 border-dark-border bg-dark-bg">
|
||||
<Input
|
||||
placeholder={t("fileManager.searchFilesAndFolders")}
|
||||
className="w-full h-7 text-sm bg-dark-bg-button border-2 border-dark-border-hover text-white placeholder:text-muted-foreground rounded-md"
|
||||
autoComplete="off"
|
||||
value={fileSearch}
|
||||
onChange={(e) => setFileSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 w-full bg-dark-bg-darkest border-t-1 border-dark-border">
|
||||
<ScrollArea className="h-full w-full bg-dark-bg-darkest">
|
||||
<div className="p-2 pb-0">
|
||||
{connectingSSH || filesLoading ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("fileManager.noFilesOrFoldersFound")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredFiles.map((item: any) => {
|
||||
const isOpen = (tabs || []).some(
|
||||
(t: any) => t.id === item.path,
|
||||
);
|
||||
const isRenaming =
|
||||
renamingItem?.item?.path === item.path;
|
||||
const isDeleting = false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded group max-w-[220px] mb-2 relative",
|
||||
isOpen &&
|
||||
"opacity-60 cursor-not-allowed pointer-events-none",
|
||||
)}
|
||||
onContextMenu={(e) =>
|
||||
!isOpen && handleContextMenu(e, item)
|
||||
}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{item.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : item.type === "link" ? (
|
||||
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<Input
|
||||
value={renamingItem.newName}
|
||||
onChange={(e) =>
|
||||
setRenamingItem((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
newName: e.target.value,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleRename(
|
||||
item,
|
||||
renamingItem.newName,
|
||||
);
|
||||
} else if (e.key === "Escape") {
|
||||
setRenamingItem(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() =>
|
||||
handleRename(item, renamingItem.newName)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() =>
|
||||
!isOpen &&
|
||||
(item.type === "directory"
|
||||
? handlePathChange(item.path)
|
||||
: item.type === "link"
|
||||
? handleSymlinkClick(item)
|
||||
: onOpenFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
isSSH: item.isSSH,
|
||||
sshSessionId: item.sshSessionId,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : item.type === "link" ? (
|
||||
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-white truncate flex-1 min-w-0">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{item.type === "file" && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
disabled={isOpen}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (item.isPinned) {
|
||||
await removeFileManagerPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: host?.id,
|
||||
isSSH: true,
|
||||
sshSessionId:
|
||||
host?.id.toString(),
|
||||
});
|
||||
setFiles(
|
||||
files.map((f) =>
|
||||
f.path === item.path
|
||||
? {
|
||||
...f,
|
||||
isPinned: false,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await addFileManagerPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: host?.id,
|
||||
isSSH: true,
|
||||
sshSessionId:
|
||||
host?.id.toString(),
|
||||
});
|
||||
setFiles(
|
||||
files.map((f) =>
|
||||
f.path === item.path
|
||||
? {
|
||||
...f,
|
||||
isPinned: true,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {}
|
||||
}}
|
||||
>
|
||||
<Pin
|
||||
className={`w-1 h-1 ${item.isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{!isOpen && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, item);
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contextMenu.visible && contextMenu.item && (
|
||||
<div
|
||||
className="fixed z-[99998] bg-dark-bg border-2 border-dark-border rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-dark-hover flex items-center gap-2"
|
||||
onClick={() => startRename(contextMenu.item)}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-dark-hover flex items-center gap-2"
|
||||
onClick={() => startDelete(contextMenu.item)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { FileManagerLeftSidebar };
|
||||
@@ -1,141 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card } from "@/components/ui/card.tsx";
|
||||
import { Folder, File, Trash2, Pin, Download } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SSHConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface FileManagerLeftSidebarVileViewerProps {
|
||||
sshConnections: SSHConnection[];
|
||||
onAddSSH: () => void;
|
||||
onConnectSSH: (conn: SSHConnection) => void;
|
||||
onEditSSH: (conn: SSHConnection) => void;
|
||||
onDeleteSSH: (conn: SSHConnection) => void;
|
||||
onPinSSH: (conn: SSHConnection) => void;
|
||||
currentPath: string;
|
||||
files: FileItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onOpenFolder: (folder: FileItem) => void;
|
||||
onStarFile: (file: FileItem) => void;
|
||||
onDownloadFile?: (file: FileItem) => void;
|
||||
onDeleteFile: (file: FileItem) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
isSSHMode: boolean;
|
||||
onSwitchToLocal: () => void;
|
||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
||||
currentSSH?: SSHConnection;
|
||||
}
|
||||
|
||||
export function FileManagerLeftSidebarFileViewer({
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDownloadFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
}: FileManagerLeftSidebarVileViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{isSSHMode ? t("common.sshPath") : t("common.localPath")}
|
||||
</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500">{error}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map((item) => (
|
||||
<Card
|
||||
key={item.path}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
onClick={() =>
|
||||
item.type === "directory"
|
||||
? onOpenFolder(item)
|
||||
: onOpenFile(item)
|
||||
}
|
||||
>
|
||||
{item.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm text-white truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onStarFile(item)}
|
||||
>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
{item.type === "file" && onDownloadFile && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onDownloadFile(item)}
|
||||
title={t("fileManager.downloadFile")}
|
||||
>
|
||||
<Download className="w-4 h-4 text-blue-400" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onDeleteFile(item)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{files.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No files or folders found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,980 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Card } from "@/components/ui/card.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
FilePlus,
|
||||
FolderPlus,
|
||||
Trash2,
|
||||
Edit3,
|
||||
X,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Folder,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileManagerOperationsProps } from "../../../types/index.js";
|
||||
|
||||
export function FileManagerOperations({
|
||||
currentPath,
|
||||
sshSessionId,
|
||||
onOperationComplete,
|
||||
onError,
|
||||
onSuccess,
|
||||
}: FileManagerOperationsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showDownload, setShowDownload] = useState(false);
|
||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showRename, setShowRename] = useState(false);
|
||||
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [downloadPath, setDownloadPath] = useState("");
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [deletePath, setDeletePath] = useState("");
|
||||
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
|
||||
const [renamePath, setRenamePath] = useState("");
|
||||
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showTextLabels, setShowTextLabels] = useState(true);
|
||||
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 () => {
|
||||
if (!uploadFile || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.uploadingFile", { name: uploadFile.name }),
|
||||
);
|
||||
|
||||
try {
|
||||
// Read file content - support text and binary files
|
||||
const content = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error);
|
||||
|
||||
// Check file type to determine reading method
|
||||
const isTextFile =
|
||||
uploadFile.type.startsWith("text/") ||
|
||||
uploadFile.type === "application/json" ||
|
||||
uploadFile.type === "application/javascript" ||
|
||||
uploadFile.type === "application/xml" ||
|
||||
uploadFile.name.match(
|
||||
/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i,
|
||||
);
|
||||
|
||||
if (isTextFile) {
|
||||
reader.onload = () => {
|
||||
if (reader.result) {
|
||||
resolve(reader.result as string);
|
||||
} else {
|
||||
reject(new Error("Failed to read text file content"));
|
||||
}
|
||||
};
|
||||
reader.readAsText(uploadFile);
|
||||
} else {
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(reader.result);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to read binary file"));
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(uploadFile);
|
||||
}
|
||||
});
|
||||
|
||||
const { uploadSSHFile } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await uploadSSHFile(
|
||||
sshSessionId,
|
||||
currentPath,
|
||||
uploadFile.name,
|
||||
content,
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.fileUploadedSuccessfully", { name: uploadFile.name }),
|
||||
);
|
||||
}
|
||||
|
||||
setShowUpload(false);
|
||||
setUploadFile(null);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToUploadFile"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFile = async () => {
|
||||
if (!newFileName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.creatingFile", { name: newFileName.trim() }),
|
||||
);
|
||||
|
||||
try {
|
||||
const { createSSHFile } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await createSSHFile(
|
||||
sshSessionId,
|
||||
currentPath,
|
||||
newFileName.trim(),
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.fileCreatedSuccessfully", {
|
||||
name: newFileName.trim(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setShowCreateFile(false);
|
||||
setNewFileName("");
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToCreateFile"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!downloadPath.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const fileName = downloadPath.split("/").pop() || "download";
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.downloadingFile", { name: fileName }),
|
||||
);
|
||||
|
||||
try {
|
||||
const { downloadSSHFile } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await downloadSSHFile(sshSessionId, downloadPath.trim());
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.content) {
|
||||
// Convert base64 to blob and trigger download
|
||||
const byteCharacters = atob(response.content);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], {
|
||||
type: response.mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = response.fileName || fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
onSuccess(
|
||||
t("fileManager.fileDownloadedSuccessfully", {
|
||||
name: response.fileName || fileName,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
onError(t("fileManager.noFileContent"));
|
||||
}
|
||||
|
||||
setShowDownload(false);
|
||||
setDownloadPath("");
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToDownloadFile"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.creatingFolder", { name: newFolderName.trim() }),
|
||||
);
|
||||
|
||||
try {
|
||||
const { createSSHFolder } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await createSSHFolder(
|
||||
sshSessionId,
|
||||
currentPath,
|
||||
newFolderName.trim(),
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.folderCreatedSuccessfully", {
|
||||
name: newFolderName.trim(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setShowCreateFolder(false);
|
||||
setNewFolderName("");
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToCreateFolder"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletePath || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.deletingItem", {
|
||||
type: deleteIsDirectory
|
||||
? t("fileManager.folder")
|
||||
: t("fileManager.file"),
|
||||
name: deletePath.split("/").pop(),
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const { deleteSSHItem } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await deleteSSHItem(
|
||||
sshSessionId,
|
||||
deletePath,
|
||||
deleteIsDirectory,
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.itemDeletedSuccessfully", {
|
||||
type: deleteIsDirectory
|
||||
? t("fileManager.folder")
|
||||
: t("fileManager.file"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setShowDelete(false);
|
||||
setDeletePath("");
|
||||
setDeleteIsDirectory(false);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.renamingItem", {
|
||||
type: renameIsDirectory
|
||||
? t("fileManager.folder")
|
||||
: t("fileManager.file"),
|
||||
oldName: renamePath.split("/").pop(),
|
||||
newName: newName.trim(),
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const { renameSSHItem } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await renameSSHItem(
|
||||
sshSessionId,
|
||||
renamePath,
|
||||
newName.trim(),
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.itemRenamedSuccessfully", {
|
||||
type: renameIsDirectory
|
||||
? t("fileManager.folder")
|
||||
: t("fileManager.file"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setShowRename(false);
|
||||
setRenamePath("");
|
||||
setRenameIsDirectory(false);
|
||||
setNewName("");
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setUploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const resetStates = () => {
|
||||
setShowUpload(false);
|
||||
setShowCreateFile(false);
|
||||
setShowCreateFolder(false);
|
||||
setShowDelete(false);
|
||||
setShowRename(false);
|
||||
setUploadFile(null);
|
||||
setNewFileName("");
|
||||
setNewFolderName("");
|
||||
setDeletePath("");
|
||||
setDeleteIsDirectory(false);
|
||||
setRenamePath("");
|
||||
setRenameIsDirectory(false);
|
||||
setNewName("");
|
||||
};
|
||||
|
||||
if (!sshSessionId) {
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fileManager.connectToSsh")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||
title={t("fileManager.uploadFile")}
|
||||
>
|
||||
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.uploadFile")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDownload(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||
title={t("fileManager.downloadFile")}
|
||||
>
|
||||
<Download className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.downloadFile")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFile(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||
title={t("fileManager.newFile")}
|
||||
>
|
||||
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.newFile")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFolder(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||
title={t("fileManager.newFolder")}
|
||||
>
|
||||
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.newFolder")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRename(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||
title={t("fileManager.rename")}
|
||||
>
|
||||
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.rename")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-3"
|
||||
title={t("fileManager.deleteItem")}
|
||||
>
|
||||
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.deleteItem")}</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-bg-light border-2 border-dark-border-medium rounded-md p-3">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-muted-foreground block mb-1">
|
||||
{t("fileManager.currentPath")}:
|
||||
</span>
|
||||
<span className="text-white font-mono text-xs break-all leading-relaxed">
|
||||
{currentPath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.25 bg-dark-border" />
|
||||
|
||||
{showUpload && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
|
||||
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.uploadFileTitle")}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("fileManager.maxFileSize")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowUpload(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="border-2 border-dashed border-dark-border-hover rounded-lg p-4 text-center">
|
||||
{uploadFile ? (
|
||||
<div className="space-y-3">
|
||||
<FileText className="w-12 h-12 text-blue-400 mx-auto" />
|
||||
<p className="text-white font-medium text-sm break-words px-2">
|
||||
{uploadFile.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(uploadFile.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUploadFile(null)}
|
||||
className="w-full text-sm h-8"
|
||||
>
|
||||
{t("fileManager.removeFile")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-12 h-12 text-muted-foreground mx-auto" />
|
||||
<p className="text-white text-sm break-words px-2">
|
||||
{t("fileManager.clickToSelectFile")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openFileDialog}
|
||||
className="w-full text-sm h-8"
|
||||
>
|
||||
{t("fileManager.chooseFile")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
accept="*/*"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleFileUpload}
|
||||
disabled={!uploadFile || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.uploading")
|
||||
: t("fileManager.uploadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowUpload(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showDownload && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Download className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.downloadFile")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDownload(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.filePath")}
|
||||
</label>
|
||||
<Input
|
||||
value={downloadPath}
|
||||
onChange={(e) => setDownloadPath(e.target.value)}
|
||||
placeholder={t("placeholders.fullPath")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleDownload()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
disabled={!downloadPath.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.downloading")
|
||||
: t("fileManager.downloadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDownload(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showCreateFile && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.createNewFile")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFile(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.fileName")}
|
||||
</label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder={t("placeholders.fileName")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleCreateFile}
|
||||
disabled={!newFileName.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.creating")
|
||||
: t("fileManager.createFile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateFile(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showCreateFolder && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<FolderPlus className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.createNewFolder")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.folderName")}
|
||||
</label>
|
||||
<Input
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder={t("placeholders.folderName")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleCreateFolder}
|
||||
disabled={!newFolderName.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.creating")
|
||||
: t("fileManager.createFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showDelete && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.deleteItem")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDelete(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2 text-red-300">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm font-medium break-words">
|
||||
{t("fileManager.warningCannotUndo")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.itemPath")}
|
||||
</label>
|
||||
<Input
|
||||
value={deletePath}
|
||||
onChange={(e) => setDeletePath(e.target.value)}
|
||||
placeholder={t("placeholders.fullPath")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="deleteIsDirectory"
|
||||
checked={deleteIsDirectory}
|
||||
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
||||
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<label
|
||||
htmlFor="deleteIsDirectory"
|
||||
className="text-sm text-white break-words"
|
||||
>
|
||||
{t("fileManager.thisIsDirectory")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={!deletePath || isLoading}
|
||||
variant="destructive"
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.deleting")
|
||||
: t("fileManager.deleteItem")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDelete(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showRename && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Edit3 className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.renameItem")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowRename(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.currentPathLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={renamePath}
|
||||
onChange={(e) => setRenamePath(e.target.value)}
|
||||
placeholder={t("placeholders.currentPath")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.newName")}
|
||||
</label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("placeholders.newName")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRename()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="renameIsDirectory"
|
||||
checked={renameIsDirectory}
|
||||
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
||||
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<label
|
||||
htmlFor="renameIsDirectory"
|
||||
className="text-sm text-white break-words"
|
||||
>
|
||||
{t("fileManager.thisIsDirectoryRename")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleRename}
|
||||
disabled={!renamePath || !newName.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.renaming")
|
||||
: t("fileManager.renameItem")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowRename(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { X, Home } from "lucide-react";
|
||||
|
||||
interface FileManagerTab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface FileManagerTabList {
|
||||
tabs: FileManagerTab[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function FileManagerTabList({
|
||||
tabs,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
closeTab,
|
||||
onHomeClick,
|
||||
}: FileManagerTabList) {
|
||||
return (
|
||||
<div className="inline-flex items-center h-full gap-2">
|
||||
<Button
|
||||
onClick={onHomeClick}
|
||||
variant="outline"
|
||||
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
</Button>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="inline-flex rounded-md shadow-sm"
|
||||
role="group"
|
||||
>
|
||||
<Button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="outline"
|
||||
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="outline"
|
||||
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
|
||||
>
|
||||
<X className="!w-4 !h-4" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Save,
|
||||
RotateCcw,
|
||||
Keyboard,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SiJavascript,
|
||||
@@ -49,7 +50,7 @@ import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { searchKeymap, search } from "@codemirror/search";
|
||||
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
||||
import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands";
|
||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||
@@ -323,6 +324,7 @@ export function FileViewer({
|
||||
const [pdfScale, setPdfScale] = useState(1.2);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
const fileTypeInfo = getFileType(file.name);
|
||||
|
||||
@@ -348,7 +350,8 @@ export function FileViewer({
|
||||
if (savedContent) {
|
||||
setOriginalContent(savedContent);
|
||||
}
|
||||
setHasChanges(content !== (savedContent || content));
|
||||
// Fix: Compare current content with saved content properly
|
||||
setHasChanges(content !== savedContent);
|
||||
|
||||
// If unknown file type and file is large, show warning
|
||||
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
||||
@@ -361,7 +364,8 @@ export function FileViewer({
|
||||
// Handle content changes
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setEditedContent(newContent);
|
||||
setHasChanges(newContent !== originalContent);
|
||||
// Fix: Compare with savedContent instead of originalContent for consistency
|
||||
setHasChanges(newContent !== savedContent);
|
||||
onContentChange?.(newContent);
|
||||
};
|
||||
|
||||
@@ -373,9 +377,9 @@ export function FileViewer({
|
||||
|
||||
// Revert file
|
||||
const handleRevert = () => {
|
||||
setEditedContent(originalContent);
|
||||
setEditedContent(savedContent);
|
||||
setHasChanges(false);
|
||||
onContentChange?.(originalContent);
|
||||
onContentChange?.(savedContent);
|
||||
};
|
||||
|
||||
// Handle save shortcut specifically
|
||||
@@ -453,6 +457,26 @@ export function FileViewer({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search button */}
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Use CodeMirror's proper API to open search panel
|
||||
if (editorRef.current) {
|
||||
const view = editorRef.current.view;
|
||||
if (view) {
|
||||
openSearchPanel(view);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
title="Search in file (Ctrl+F)"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Keyboard shortcuts help */}
|
||||
{isEditable && (
|
||||
<Button
|
||||
@@ -557,14 +581,13 @@ export function FileViewer({
|
||||
<span>{t("fileManager.redo")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Y</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-muted-foreground">{t("fileManager.navigation")}</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.goToLine")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+G</kbd>
|
||||
<span>{t("fileManager.toggleComment")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.autoComplete")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.moveLineUp")}</span>
|
||||
@@ -576,27 +599,6 @@ export function FileViewer({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-muted-foreground">{t("fileManager.code")}</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.toggleComment")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.indent")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Tab</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.outdent")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Shift+Tab</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.autoComplete")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -737,6 +739,7 @@ export function FileViewer({
|
||||
{isEditable ? (
|
||||
// Unified CodeMirror editor for all text-based files
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={editedContent}
|
||||
onChange={(value) => handleContentChange(value)}
|
||||
onFocus={() => setEditorFocused(true)}
|
||||
@@ -906,17 +909,7 @@ export function FileViewer({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onSave?.(editedContent)}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{t("fileManager.save")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Save button removed - using the main header save button instead */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -525,13 +525,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const sampleData = {
|
||||
hosts: [
|
||||
{
|
||||
name: "Web Server - Production",
|
||||
name: t("interface.webServerProduction"),
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "your_secure_password_here",
|
||||
folder: "Production",
|
||||
folder: t("interface.productionFolder"),
|
||||
tags: ["web", "production", "nginx"],
|
||||
pin: true,
|
||||
enableTerminal: true,
|
||||
@@ -540,7 +540,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
defaultPath: "/var/www",
|
||||
},
|
||||
{
|
||||
name: "Database Server",
|
||||
name: t("interface.databaseServer"),
|
||||
ip: "192.168.1.101",
|
||||
port: 22,
|
||||
username: "dbadmin",
|
||||
@@ -548,7 +548,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||
keyPassword: "optional_key_passphrase",
|
||||
keyType: "ssh-ed25519",
|
||||
folder: "Production",
|
||||
folder: t("interface.productionFolder"),
|
||||
tags: ["database", "production", "postgresql"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
@@ -558,7 +558,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{
|
||||
sourcePort: 5432,
|
||||
endpointPort: 5432,
|
||||
endpointHost: "Web Server - Production",
|
||||
endpointHost: t("interface.webServerProduction"),
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true,
|
||||
@@ -566,13 +566,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Development Server",
|
||||
name: t("interface.developmentServer"),
|
||||
ip: "192.168.1.102",
|
||||
port: 2222,
|
||||
username: "developer",
|
||||
authType: "credential",
|
||||
credentialId: 1,
|
||||
folder: "Development",
|
||||
folder: t("interface.developmentFolder"),
|
||||
tags: ["dev", "testing"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
@@ -686,13 +686,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const sampleData = {
|
||||
hosts: [
|
||||
{
|
||||
name: "Web Server - Production",
|
||||
name: t("interface.webServerProduction"),
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "your_secure_password_here",
|
||||
folder: "Production",
|
||||
folder: t("interface.productionFolder"),
|
||||
tags: ["web", "production", "nginx"],
|
||||
pin: true,
|
||||
enableTerminal: true,
|
||||
@@ -701,7 +701,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
defaultPath: "/var/www",
|
||||
},
|
||||
{
|
||||
name: "Database Server",
|
||||
name: t("interface.databaseServer"),
|
||||
ip: "192.168.1.101",
|
||||
port: 22,
|
||||
username: "dbadmin",
|
||||
@@ -709,7 +709,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||
keyPassword: "optional_key_passphrase",
|
||||
keyType: "ssh-ed25519",
|
||||
folder: "Production",
|
||||
folder: t("interface.productionFolder"),
|
||||
tags: ["database", "production", "postgresql"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
@@ -719,7 +719,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{
|
||||
sourcePort: 5432,
|
||||
endpointPort: 5432,
|
||||
endpointHost: "Web Server - Production",
|
||||
endpointHost: t("interface.webServerProduction"),
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true,
|
||||
@@ -727,13 +727,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Development Server",
|
||||
name: t("interface.developmentServer"),
|
||||
ip: "192.168.1.102",
|
||||
port: 2222,
|
||||
username: "developer",
|
||||
authType: "credential",
|
||||
credentialId: 1,
|
||||
folder: "Development",
|
||||
folder: t("interface.developmentFolder"),
|
||||
tags: ["dev", "testing"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
|
||||
@@ -40,6 +40,7 @@ export function Server({
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
@@ -116,10 +117,12 @@ export function Server({
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) {
|
||||
setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||
}
|
||||
} finally {
|
||||
@@ -208,6 +211,7 @@ export function Server({
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
@@ -219,6 +223,7 @@ export function Server({
|
||||
setServerStatus("offline");
|
||||
}
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
@@ -267,7 +272,8 @@ export function Server({
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||
{showStatsUI && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -443,7 +449,8 @@ export function Server({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Tunnels */}
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
|
||||
@@ -304,18 +304,18 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082`
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as any).configuredServerUrl || "http://127.0.0.1:8081";
|
||||
(window as any).configuredServerUrl || "http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/`;
|
||||
return `${wsProtocol}${wsHost.replace(':30001', ':30002')}/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:8082/`;
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:30002/`;
|
||||
|
||||
// Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity
|
||||
if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) {
|
||||
|
||||
@@ -146,7 +146,7 @@ export function ServerConfig({
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="http://localhost:8081 or https://your-server.com"
|
||||
placeholder="http://localhost:30001 or https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className="flex-1 h-10"
|
||||
|
||||
@@ -27,13 +27,11 @@ export function HomepageAlertManager({
|
||||
}, [loggedIn, userId]);
|
||||
|
||||
const fetchUserAlerts = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getUserAlerts(userId);
|
||||
const response = await getUserAlerts();
|
||||
const userAlerts = response.alerts || [];
|
||||
|
||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||
@@ -64,10 +62,8 @@ export function HomepageAlertManager({
|
||||
};
|
||||
|
||||
const handleDismissAlert = async (alertId: string) => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
await dismissAlert(userId, alertId);
|
||||
await dismissAlert(alertId);
|
||||
|
||||
setAlerts((prev) => {
|
||||
const newAlerts = prev.filter((alert) => alert.id !== alertId);
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -124,20 +124,32 @@ export function HomepageAuth({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setDbHealthChecking(true);
|
||||
getSetupRequired()
|
||||
.then((res) => {
|
||||
if (res.setup_required) {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
toast.info(t("auth.firstUserMessage"));
|
||||
} else {
|
||||
setFirstUser(false);
|
||||
}
|
||||
setDbError(null);
|
||||
setDbConnectionFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setDbError(t("errors.databaseConnection"));
|
||||
setDbConnectionFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setDbHealthChecking(false);
|
||||
});
|
||||
}, [setDbError]);
|
||||
}, [setDbError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registrationAllowed && !internalLoggedIn) {
|
||||
toast.warning(t("messages.registrationDisabled"));
|
||||
}
|
||||
}, [registrationAllowed, internalLoggedIn, t]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -145,7 +157,7 @@ export function HomepageAuth({
|
||||
setLoading(true);
|
||||
|
||||
if (!localUsername.trim()) {
|
||||
setError(t("errors.requiredField"));
|
||||
toast.error(t("errors.requiredField"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -156,12 +168,12 @@ export function HomepageAuth({
|
||||
res = await loginUser(localUsername, password);
|
||||
} else {
|
||||
if (password !== signupConfirmPassword) {
|
||||
setError(t("errors.passwordMismatch"));
|
||||
toast.error(t("errors.passwordMismatch"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError(t("errors.minLength", { min: 6 }));
|
||||
toast.error(t("errors.minLength", { min: 6 }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -199,22 +211,25 @@ export function HomepageAuth({
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.id || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.id || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
if (tab === "signup") {
|
||||
setSignupConfirmPassword("");
|
||||
toast.success(t("messages.registrationSuccess"));
|
||||
} else {
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
}
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
toast.error(
|
||||
err?.response?.data?.error || err?.message || t("errors.unknownError"),
|
||||
);
|
||||
setInternalLoggedIn(false);
|
||||
@@ -224,7 +239,7 @@ export function HomepageAuth({
|
||||
setUserId(null);
|
||||
setCookie("jwt", "", -1);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(t("errors.databaseConnection"));
|
||||
setDbConnectionFailed(true);
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
@@ -239,9 +254,9 @@ export function HomepageAuth({
|
||||
try {
|
||||
const result = await initiatePasswordReset(localUsername);
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
toast.success(t("messages.resetCodeSent"));
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
t("errors.failedPasswordReset"),
|
||||
@@ -258,9 +273,9 @@ export function HomepageAuth({
|
||||
const response = await verifyPasswordResetCode(localUsername, resetCode);
|
||||
setTempToken(response.tempToken);
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
toast.success(t("messages.codeVerified"));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || t("errors.failedVerifyCode"));
|
||||
toast.error(err?.response?.data?.error || t("errors.failedVerifyCode"));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -271,13 +286,13 @@ export function HomepageAuth({
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("errors.passwordMismatch"));
|
||||
toast.error(t("errors.passwordMismatch"));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError(t("errors.minLength", { min: 6 }));
|
||||
toast.error(t("errors.minLength", { min: 6 }));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -293,8 +308,9 @@ export function HomepageAuth({
|
||||
setError(null);
|
||||
|
||||
setResetSuccess(true);
|
||||
toast.success(t("messages.passwordResetSuccess"));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || t("errors.failedCompleteReset"));
|
||||
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -319,7 +335,7 @@ export function HomepageAuth({
|
||||
|
||||
async function handleTOTPVerification() {
|
||||
if (totpCode.length !== 6) {
|
||||
setError(t("auth.enterCode"));
|
||||
toast.error(t("auth.enterCode"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -340,19 +356,20 @@ export function HomepageAuth({
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.id || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.id || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
t("errors.invalidTotpCode"),
|
||||
@@ -375,7 +392,7 @@ export function HomepageAuth({
|
||||
|
||||
window.location.replace(authUrl);
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
t("errors.failedOidcLogin"),
|
||||
@@ -391,7 +408,7 @@ export function HomepageAuth({
|
||||
const error = urlParams.get("error");
|
||||
|
||||
if (error) {
|
||||
setError(`${t("errors.oidcAuthFailed")}: ${error}`);
|
||||
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
@@ -408,12 +425,12 @@ export function HomepageAuth({
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.id || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.id || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
window.history.replaceState(
|
||||
@@ -423,7 +440,7 @@ export function HomepageAuth({
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(t("errors.failedUserInfo"));
|
||||
toast.error(t("errors.failedUserInfo"));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
@@ -468,6 +485,34 @@ export function HomepageAuth({
|
||||
null,
|
||||
);
|
||||
const [currentServerUrl, setCurrentServerUrl] = useState<string>("");
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
const [dbHealthChecking, setDbHealthChecking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dbConnectionFailed) {
|
||||
toast.error(t("errors.databaseConnection"));
|
||||
}
|
||||
}, [dbConnectionFailed, t]);
|
||||
|
||||
const retryDatabaseConnection = async () => {
|
||||
setDbHealthChecking(true);
|
||||
setDbConnectionFailed(false);
|
||||
try {
|
||||
const res = await getSetupRequired();
|
||||
if (res.setup_required) {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
} else {
|
||||
setFirstUser(false);
|
||||
}
|
||||
setDbError(null);
|
||||
toast.success(t("messages.databaseConnected"));
|
||||
} catch (error) {
|
||||
setDbConnectionFailed(true);
|
||||
} finally {
|
||||
setDbHealthChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkServerConfig = async () => {
|
||||
@@ -519,42 +564,91 @@ export function HomepageAuth({
|
||||
);
|
||||
}
|
||||
|
||||
if (dbHealthChecking && !dbConnectionFailed) {
|
||||
return (
|
||||
<div
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("common.checkingDatabase")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dbConnectionFailed) {
|
||||
return (
|
||||
<div
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
||||
{...props}
|
||||
>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{t("errors.databaseConnection")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("messages.databaseConnectionFailed")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={dbHealthChecking}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-dark-border space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
Server
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{currentServerUrl}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md ${className || ""}`}
|
||||
{...props}
|
||||
>
|
||||
{dbError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{dbError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{firstUser && !dbError && !internalLoggedIn && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<AlertTitle>{t("auth.firstUser")}</AlertTitle>
|
||||
<AlertDescription className="inline">
|
||||
{t("auth.firstUserMessage")}{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline hover:text-blue-800 inline"
|
||||
>
|
||||
GitHub Issue
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!registrationAllowed && !internalLoggedIn && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>{t("auth.registerTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("messages.registrationDisabled")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{totpRequired && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="mb-6 text-center">
|
||||
@@ -805,14 +899,11 @@ export function HomepageAuth({
|
||||
|
||||
{resetSuccess && (
|
||||
<>
|
||||
<Alert className="mb-4">
|
||||
<AlertTitle>
|
||||
{t("auth.passwordResetSuccess")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/20 mb-4">
|
||||
<p className="text-green-400 text-sm">
|
||||
{t("auth.passwordResetSuccessDesc")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
@@ -998,12 +1089,6 @@ export function HomepageAuth({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -361,8 +361,8 @@ export function LeftSidebar({
|
||||
</div>
|
||||
|
||||
{hostsError && (
|
||||
<div className="px-1">
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||
<div className="!bg-dark-bg-input rounded-lg">
|
||||
<div className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md px-3 py-1.5 flex items-center text-red-500">
|
||||
{t("leftSidebar.failedToLoadHosts")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -311,17 +311,17 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
window.location.port === "");
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082`
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as any).configuredServerUrl ||
|
||||
"http://127.0.0.1:8081";
|
||||
"http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/ssh/websocket/`;
|
||||
return `${wsProtocol}${wsHost.replace(':30001', ':30002')}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ function createApiInstance(
|
||||
// Handle DEK (Data Encryption Key) invalidation
|
||||
if (status === 423) {
|
||||
const errorData = error.response?.data;
|
||||
if (errorData?.error === "DATA_LOCKED" || errorData?.message?.includes("DATA_LOCKED")) {
|
||||
if ((errorData as any)?.error === "DATA_LOCKED" || (errorData as any)?.message?.includes("DATA_LOCKED")) {
|
||||
// DEK session has expired (likely due to server restart or timeout)
|
||||
// Force logout to require re-authentication and DEK unlock
|
||||
if (isElectron()) {
|
||||
@@ -324,11 +324,11 @@ function isDev(): boolean {
|
||||
}
|
||||
|
||||
let apiHost = import.meta.env.VITE_API_HOST || "localhost";
|
||||
let apiPort = 8081;
|
||||
let apiPort = 30001;
|
||||
let configuredServerUrl: string | null = null;
|
||||
|
||||
if (isElectron()) {
|
||||
apiPort = 8081;
|
||||
apiPort = 30001;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
@@ -416,38 +416,38 @@ function getApiUrl(path: string, defaultPort: number): string {
|
||||
|
||||
// Initialize API instances
|
||||
function initializeApiInstances() {
|
||||
// SSH Host Management API (port 8081)
|
||||
sshHostApi = createApiInstance(getApiUrl("/ssh", 8081), "SSH_HOST");
|
||||
// SSH Host Management API (port 30001)
|
||||
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
|
||||
|
||||
// Tunnel Management API (port 8083)
|
||||
tunnelApi = createApiInstance(getApiUrl("/ssh", 8083), "TUNNEL");
|
||||
// Tunnel Management API (port 30003)
|
||||
tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL");
|
||||
|
||||
// File Manager Operations API (port 8084)
|
||||
// File Manager Operations API (port 30004)
|
||||
fileManagerApi = createApiInstance(
|
||||
getApiUrl("/ssh/file_manager", 8084),
|
||||
getApiUrl("/ssh/file_manager", 30004),
|
||||
"FILE_MANAGER",
|
||||
);
|
||||
|
||||
// Server Statistics API (port 8085)
|
||||
statsApi = createApiInstance(getApiUrl("", 8085), "STATS");
|
||||
// Server Statistics API (port 30005)
|
||||
statsApi = createApiInstance(getApiUrl("", 30005), "STATS");
|
||||
|
||||
// Authentication API (port 8081)
|
||||
authApi = createApiInstance(getApiUrl("", 8081), "AUTH");
|
||||
// Authentication API (port 30001)
|
||||
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
||||
}
|
||||
|
||||
// SSH Host Management API (port 8081)
|
||||
// SSH Host Management API (port 30001)
|
||||
export let sshHostApi: AxiosInstance;
|
||||
|
||||
// Tunnel Management API (port 8083)
|
||||
// Tunnel Management API (port 30003)
|
||||
export let tunnelApi: AxiosInstance;
|
||||
|
||||
// File Manager Operations API (port 8084)
|
||||
// File Manager Operations API (port 30004)
|
||||
export let fileManagerApi: AxiosInstance;
|
||||
|
||||
// Server Statistics API (port 8085)
|
||||
// Server Statistics API (port 30005)
|
||||
export let statsApi: AxiosInstance;
|
||||
|
||||
// Authentication API (port 8081)
|
||||
// Authentication API (port 30001)
|
||||
export let authApi: AxiosInstance;
|
||||
|
||||
// Initialize API instances immediately
|
||||
@@ -1763,11 +1763,9 @@ export async function generateBackupCodes(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserAlerts(
|
||||
userId: string,
|
||||
): Promise<{ alerts: any[] }> {
|
||||
export async function getUserAlerts(): Promise<{ alerts: any[] }> {
|
||||
try {
|
||||
const response = await authApi.get(`/alerts/user/${userId}`);
|
||||
const response = await authApi.get(`/alerts`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch user alerts");
|
||||
@@ -1775,11 +1773,10 @@ export async function getUserAlerts(
|
||||
}
|
||||
|
||||
export async function dismissAlert(
|
||||
userId: string,
|
||||
alertId: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post("/alerts/dismiss", { userId, alertId });
|
||||
const response = await authApi.post("/alerts/dismiss", { alertId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "dismiss alert");
|
||||
|
||||
Reference in New Issue
Block a user