v1.9.0 #437
@@ -96,12 +96,6 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
});
|
||||
|
||||
try {
|
||||
databaseLogger.info(
|
||||
"Generating diagnostic information for database encryption failure",
|
||||
{
|
||||
operation: "db_encryption_diagnostic",
|
||||
},
|
||||
);
|
||||
const diagnosticInfo =
|
||||
DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath);
|
||||
databaseLogger.error(
|
||||
|
||||
@@ -279,7 +279,6 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the snippet
|
||||
const snippetResult = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
@@ -296,11 +295,9 @@ router.post(
|
||||
|
||||
const snippet = snippetResult[0];
|
||||
|
||||
// Import SSH connection utilities
|
||||
const { Client } = await import("ssh2");
|
||||
const { sshData, sshCredentials } = await import("../db/schema.js");
|
||||
|
||||
// Get host configuration using SimpleDBOps to decrypt credentials
|
||||
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
|
||||
|
||||
const hostResult = await SimpleDBOps.select(
|
||||
@@ -320,7 +317,6 @@ router.post(
|
||||
|
||||
const host = hostResult[0];
|
||||
|
||||
// Resolve credentials if needed
|
||||
let password = host.password;
|
||||
let privateKey = host.key;
|
||||
let passphrase = host.key_password;
|
||||
@@ -352,7 +348,6 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
// Create SSH connection
|
||||
const conn = new Client();
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
@@ -400,7 +395,6 @@ router.post(
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Connect to SSH
|
||||
const config: any = {
|
||||
host: host.ip,
|
||||
port: host.port,
|
||||
@@ -471,7 +465,6 @@ router.post(
|
||||
},
|
||||
};
|
||||
|
||||
// Set auth based on authType (like terminal.ts does)
|
||||
if (authType === "password" && password) {
|
||||
config.password = password;
|
||||
} else if (authType === "key" && privateKey) {
|
||||
@@ -484,10 +477,8 @@ router.post(
|
||||
config.passphrase = passphrase;
|
||||
}
|
||||
} else if (password) {
|
||||
// Fallback: if authType not set but password exists
|
||||
config.password = password;
|
||||
} else if (privateKey) {
|
||||
// Fallback: if authType not set but key exists
|
||||
const cleanKey = (privateKey as string)
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
|
||||
@@ -361,7 +361,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Notify stats server to start polling this host
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
@@ -603,7 +602,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Notify stats server to refresh polling for this host
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
@@ -893,7 +891,6 @@ router.delete(
|
||||
|
||||
const numericHostId = Number(hostId);
|
||||
|
||||
// Delete related records first to avoid foreign key constraint errors
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(
|
||||
@@ -948,7 +945,6 @@ router.delete(
|
||||
),
|
||||
);
|
||||
|
||||
// Now delete the host itself
|
||||
await db
|
||||
.delete(sshData)
|
||||
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
|
||||
@@ -966,7 +962,6 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
// Notify stats server to stop polling this host
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
@@ -1553,7 +1548,6 @@ router.put(
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_rename");
|
||||
|
||||
// Also update folder metadata if exists
|
||||
await db
|
||||
.update(sshFolders)
|
||||
.set({
|
||||
@@ -1620,7 +1614,6 @@ router.put(
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if folder metadata exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
@@ -1628,7 +1621,6 @@ router.put(
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing
|
||||
await db
|
||||
.update(sshFolders)
|
||||
.set({
|
||||
@@ -1638,7 +1630,6 @@ router.put(
|
||||
})
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)));
|
||||
} else {
|
||||
// Create new
|
||||
await db.insert(sshFolders).values({
|
||||
userId,
|
||||
name,
|
||||
@@ -1677,7 +1668,6 @@ router.delete(
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all hosts in the folder
|
||||
const hostsToDelete = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
@@ -1690,12 +1680,10 @@ router.delete(
|
||||
});
|
||||
}
|
||||
|
||||
// Delete all hosts
|
||||
await db
|
||||
.delete(sshData)
|
||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||
|
||||
// Delete folder metadata
|
||||
await db
|
||||
.delete(sshFolders)
|
||||
.where(
|
||||
@@ -1704,14 +1692,6 @@ router.delete(
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
|
||||
|
||||
sshLogger.info("Deleted all hosts in folder", {
|
||||
operation: "delete_folder_hosts",
|
||||
userId,
|
||||
folderName,
|
||||
deletedCount: hostsToDelete.length,
|
||||
});
|
||||
|
||||
// Notify stats server to stop polling these hosts
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = process.env.STATS_PORT || 30005;
|
||||
|
||||
@@ -85,8 +85,6 @@ router.get(
|
||||
}
|
||||
|
||||
try {
|
||||
// Get unique commands for this host, ordered by most recent
|
||||
// Use DISTINCT to avoid duplicates, but keep the most recent occurrence
|
||||
const result = await db
|
||||
.selectDistinct({ command: commandHistory.command })
|
||||
.from(commandHistory)
|
||||
@@ -97,9 +95,8 @@ router.get(
|
||||
),
|
||||
)
|
||||
.orderBy(desc(commandHistory.executedAt))
|
||||
.limit(500); // Limit to last 500 unique commands
|
||||
.limit(500);
|
||||
|
||||
// Further deduplicate in case DISTINCT didn't work perfectly
|
||||
const uniqueCommands = Array.from(new Set(result.map((r) => r.command)));
|
||||
|
||||
authLogger.info(`Fetched command history for host ${hostId}`, {
|
||||
@@ -142,7 +139,6 @@ router.post(
|
||||
try {
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
|
||||
// Delete all instances of this command for this user and host
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
|
||||
@@ -763,7 +763,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
.get();
|
||||
isFirstUser = ((countResult as { count?: number })?.count || 0) === 0;
|
||||
|
||||
// Check if registration is allowed (unless this is the first user)
|
||||
if (!isFirstUser) {
|
||||
try {
|
||||
const regRow = db.$client
|
||||
@@ -822,8 +821,8 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
: 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 7 * 24 * 60 * 60 * 1000;
|
||||
await authManager.registerOIDCUser(id, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
@@ -862,7 +861,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
// For all OIDC logins (including dual-auth), use OIDC encryption
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
||||
} catch (setupError) {
|
||||
@@ -901,7 +899,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Clear any existing JWT cookie first to prevent conflicts
|
||||
res.clearCookie("jwt", authManager.getSecureCookieOptions(req));
|
||||
|
||||
return res
|
||||
@@ -941,7 +938,6 @@ router.post("/login", async (req, res) => {
|
||||
return res.status(400).json({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
// Check rate limiting
|
||||
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("Login attempt blocked due to rate limiting", {
|
||||
@@ -995,8 +991,6 @@ router.post("/login", async (req, res) => {
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
// Only reject if user is OIDC-only (has no password set)
|
||||
// Empty string "" is treated as no password
|
||||
if (
|
||||
userRecord.is_oidc &&
|
||||
(!userRecord.password_hash || userRecord.password_hash.trim() === "")
|
||||
@@ -1040,17 +1034,13 @@ router.post("/login", async (req, res) => {
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
// For dual-auth users (has both password and OIDC), use OIDC encryption
|
||||
// For password-only users, use password-based encryption
|
||||
let dataUnlocked = false;
|
||||
if (userRecord.is_oidc) {
|
||||
// Dual-auth user: verify password then use OIDC encryption
|
||||
dataUnlocked = await authManager.authenticateOIDCUser(
|
||||
userRecord.id,
|
||||
deviceInfo.type,
|
||||
);
|
||||
} else {
|
||||
// Password-only user: use password-based encryption
|
||||
dataUnlocked = await authManager.authenticateUser(
|
||||
userRecord.id,
|
||||
password,
|
||||
@@ -1079,7 +1069,6 @@ router.post("/login", async (req, res) => {
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
// Reset rate limiter on successful login
|
||||
loginRateLimiter.resetAttempts(clientIp, username);
|
||||
|
||||
authLogger.success(`User logged in successfully: ${username}`, {
|
||||
@@ -2255,7 +2244,6 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
||||
const targetUserId = targetUser[0].id;
|
||||
|
||||
try {
|
||||
// Delete all user-related data to avoid foreign key constraints
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, targetUserId));
|
||||
@@ -2588,7 +2576,6 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify admin permissions
|
||||
const adminUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
@@ -2597,7 +2584,6 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
// Get OIDC user
|
||||
const oidcUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
@@ -2608,14 +2594,12 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
|
||||
const oidcUser = oidcUserRecords[0];
|
||||
|
||||
// Verify user is OIDC
|
||||
if (!oidcUser.is_oidc) {
|
||||
return res.status(400).json({
|
||||
error: "Source user is not an OIDC user",
|
||||
});
|
||||
}
|
||||
|
||||
// Get target password user
|
||||
const targetUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
@@ -2626,14 +2610,12 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
|
||||
const targetUser = targetUserRecords[0];
|
||||
|
||||
// Verify target user has password authentication
|
||||
if (targetUser.is_oidc || !targetUser.password_hash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if target user already has OIDC configured
|
||||
if (targetUser.client_id && targetUser.oidc_identifier) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has OIDC authentication configured",
|
||||
@@ -2649,11 +2631,10 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
// Copy OIDC configuration from OIDC user to target password user
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
is_oidc: true, // Enable OIDC login for this account
|
||||
is_oidc: true,
|
||||
oidc_identifier: oidcUser.oidc_identifier,
|
||||
client_id: oidcUser.client_id,
|
||||
client_secret: oidcUser.client_secret,
|
||||
@@ -2666,14 +2647,8 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
})
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// Re-encrypt the user's DEK with OIDC system key for dual-auth support
|
||||
// This allows OIDC login to decrypt user data without requiring password
|
||||
try {
|
||||
await authManager.convertToOIDCEncryption(targetUser.id);
|
||||
authLogger.info("Converted user encryption to OIDC for dual-auth", {
|
||||
operation: "link_convert_encryption",
|
||||
userId: targetUser.id,
|
||||
});
|
||||
} catch (encryptionError) {
|
||||
authLogger.error(
|
||||
"Failed to convert encryption to OIDC during linking",
|
||||
@@ -2683,7 +2658,6 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
userId: targetUser.id,
|
||||
},
|
||||
);
|
||||
// Rollback the OIDC configuration
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
@@ -2710,19 +2684,15 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Revoke all sessions for the OIDC user before deletion
|
||||
await authManager.revokeAllUserSessions(oidcUserId);
|
||||
authManager.logoutUser(oidcUserId);
|
||||
|
||||
// Delete OIDC user's recent activity first (to avoid NOT NULL constraint issues)
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.userId, oidcUserId));
|
||||
|
||||
// Delete the OIDC user (CASCADE will delete related data: hosts, credentials, sessions, etc.)
|
||||
await db.delete(users).where(eq(users.id, oidcUserId));
|
||||
|
||||
// Clean up OIDC user's settings
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key LIKE ?")
|
||||
.run(`user_%_${oidcUserId}`);
|
||||
|
||||
@@ -261,7 +261,7 @@ interface SSHSession {
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number; // Track number of active operations to prevent cleanup mid-operation
|
||||
activeOperations: number;
|
||||
}
|
||||
|
||||
interface PendingTOTPSession {
|
||||
@@ -286,7 +286,6 @@ const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
// Don't cleanup if there are active operations
|
||||
if (session.activeOperations > 0) {
|
||||
fileLogger.warn(
|
||||
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
|
||||
@@ -296,7 +295,6 @@ function cleanupSession(sessionId: string) {
|
||||
activeOperations: session.activeOperations,
|
||||
},
|
||||
);
|
||||
// Reschedule cleanup
|
||||
scheduleSessionCleanup(sessionId);
|
||||
return;
|
||||
}
|
||||
@@ -577,7 +575,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0, // Initialize active operations counter
|
||||
activeOperations: 0,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({ status: "success", message: "SSH connection established" });
|
||||
@@ -932,7 +930,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
client: session.client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0, // Initialize active operations counter
|
||||
activeOperations: 0,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -1076,12 +1074,12 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
sshConn.activeOperations++; // Track operation start
|
||||
sshConn.activeOperations++;
|
||||
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--; // Decrement on error
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error("SSH listFiles error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
@@ -1098,7 +1096,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
sshConn.activeOperations--; // Decrement when operation completes
|
||||
sshConn.activeOperations--;
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
@@ -2892,7 +2890,6 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||
const fileName = archivePath.split("/").pop() || "";
|
||||
const fileExt = fileName.toLowerCase();
|
||||
|
||||
// Determine extraction command based on file extension
|
||||
let extractCommand = "";
|
||||
const targetPath =
|
||||
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
|
||||
@@ -2970,13 +2967,11 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||
error: errorOutput,
|
||||
});
|
||||
|
||||
// Check if command not found
|
||||
let friendlyError = errorOutput || "Failed to extract archive";
|
||||
if (
|
||||
errorOutput.includes("command not found") ||
|
||||
errorOutput.includes("not found")
|
||||
) {
|
||||
// Detect which command is missing based on file extension
|
||||
let missingCmd = "";
|
||||
let installHint = "";
|
||||
|
||||
@@ -3076,15 +3071,12 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
// Determine compression format
|
||||
const compressionFormat = format || "zip"; // Default to zip
|
||||
const compressionFormat = format || "zip";
|
||||
let compressCommand = "";
|
||||
|
||||
// Get the directory where the first file is located
|
||||
const firstPath = paths[0];
|
||||
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
|
||||
|
||||
// Extract just the file/folder names for the command
|
||||
const fileNames = paths
|
||||
.map((p) => {
|
||||
const name = p.split("/").pop();
|
||||
@@ -3092,7 +3084,6 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
// Construct archive path
|
||||
let archivePath = "";
|
||||
if (archiveName.includes("/")) {
|
||||
archivePath = archiveName;
|
||||
@@ -3103,7 +3094,6 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
}
|
||||
|
||||
if (compressionFormat === "zip") {
|
||||
// Use zip command - need to cd to directory first
|
||||
compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`;
|
||||
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
|
||||
compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`;
|
||||
@@ -3170,7 +3160,6 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
error: errorOutput,
|
||||
});
|
||||
|
||||
// Check if command not found
|
||||
let friendlyError = errorOutput || "Failed to compress files";
|
||||
if (
|
||||
errorOutput.includes("command not found") ||
|
||||
|
||||
@@ -652,15 +652,12 @@ class PollingManager {
|
||||
|
||||
let parsed: StatsConfig;
|
||||
|
||||
// If it's already an object, use it directly
|
||||
if (typeof statsConfigStr === "object") {
|
||||
parsed = statsConfigStr;
|
||||
} else {
|
||||
// Otherwise, parse as JSON string (may be double-encoded)
|
||||
try {
|
||||
let temp: any = JSON.parse(statsConfigStr);
|
||||
|
||||
// Check if we got a string back (double-encoded JSON)
|
||||
if (typeof temp === "string") {
|
||||
temp = JSON.parse(temp);
|
||||
}
|
||||
@@ -678,7 +675,6 @@ class PollingManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with defaults, but prioritize the provided values
|
||||
const result = { ...DEFAULT_STATS_CONFIG, ...parsed };
|
||||
|
||||
return result;
|
||||
@@ -689,7 +685,6 @@ class PollingManager {
|
||||
|
||||
const existingConfig = this.pollingConfigs.get(host.id);
|
||||
|
||||
// Always clear existing timers first
|
||||
if (existingConfig) {
|
||||
if (existingConfig.statusTimer) {
|
||||
clearInterval(existingConfig.statusTimer);
|
||||
@@ -701,7 +696,6 @@ class PollingManager {
|
||||
}
|
||||
}
|
||||
|
||||
// If both checks are disabled, stop all polling and clean up
|
||||
if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
|
||||
this.pollingConfigs.delete(host.id);
|
||||
this.statusStore.delete(host.id);
|
||||
@@ -720,7 +714,6 @@ class PollingManager {
|
||||
this.pollHostStatus(host);
|
||||
|
||||
config.statusTimer = setInterval(() => {
|
||||
// Always get the latest config to check if polling is still enabled
|
||||
const latestConfig = this.pollingConfigs.get(host.id);
|
||||
if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
|
||||
this.pollHostStatus(latestConfig.host);
|
||||
@@ -733,11 +726,9 @@ class PollingManager {
|
||||
if (statsConfig.metricsEnabled) {
|
||||
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||
|
||||
// Poll immediately, but only if metrics are actually enabled
|
||||
this.pollHostMetrics(host);
|
||||
|
||||
config.metricsTimer = setInterval(() => {
|
||||
// Always get the latest config to check if polling is still enabled
|
||||
const latestConfig = this.pollingConfigs.get(host.id);
|
||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||
this.pollHostMetrics(latestConfig.host);
|
||||
@@ -747,7 +738,6 @@ class PollingManager {
|
||||
this.metricsStore.delete(host.id);
|
||||
}
|
||||
|
||||
// Set the config with the new timers
|
||||
this.pollingConfigs.set(host.id, config);
|
||||
}
|
||||
|
||||
@@ -769,14 +759,11 @@ class PollingManager {
|
||||
}
|
||||
|
||||
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
|
||||
// Double-check that metrics are still enabled before collecting
|
||||
// Always get the LATEST config from the Map, not from the closure
|
||||
const config = this.pollingConfigs.get(host.id);
|
||||
if (!config || !config.statsConfig.metricsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the host from the config to ensure we have the latest version
|
||||
const currentHost = config.host;
|
||||
|
||||
try {
|
||||
@@ -789,7 +776,6 @@ class PollingManager {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Re-check config after the async operation in case it was disabled during collection
|
||||
const latestConfig = this.pollingConfigs.get(currentHost.id);
|
||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||
statsLogger.warn("Failed to collect metrics for host", {
|
||||
|
||||
@@ -247,6 +247,7 @@ const wss = new WebSocketServer({
|
||||
}
|
||||
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
return false;
|
||||
}
|
||||
@@ -323,7 +324,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let isConnecting = false;
|
||||
let isConnected = false;
|
||||
let isCleaningUp = false;
|
||||
let isShellInitializing = false; // Track shell initialization to prevent cleanup mid-setup
|
||||
let isShellInitializing = false;
|
||||
|
||||
ws.on("close", () => {
|
||||
const userWs = userConnections.get(userId);
|
||||
@@ -681,10 +682,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn.on("ready", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
// Capture the connection reference immediately and verify it's still valid
|
||||
const conn = sshConn;
|
||||
|
||||
// Additional check: verify the connection is still active before proceeding
|
||||
if (!conn || isCleaningUp || !sshConn) {
|
||||
sshLogger.warn(
|
||||
"SSH connection was cleaned up before shell could be created",
|
||||
@@ -709,12 +708,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that we're initializing the shell to prevent cleanup
|
||||
isShellInitializing = true;
|
||||
isConnecting = false;
|
||||
isConnected = true;
|
||||
|
||||
// Verify connection is still valid right before shell() call
|
||||
if (!sshConn) {
|
||||
sshLogger.error(
|
||||
"SSH connection became null right before shell creation",
|
||||
@@ -740,7 +737,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
term: "xterm-256color",
|
||||
} as PseudoTtyOptions,
|
||||
(err, stream) => {
|
||||
// Shell initialization complete, clear the flag
|
||||
isShellInitializing = false;
|
||||
|
||||
if (err) {
|
||||
@@ -1265,7 +1261,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't cleanup if we're in the middle of shell initialization
|
||||
if (isShellInitializing) {
|
||||
sshLogger.warn(
|
||||
"Cleanup attempted during shell initialization, deferring",
|
||||
@@ -1274,7 +1269,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
userId,
|
||||
},
|
||||
);
|
||||
// Retry cleanup after a short delay
|
||||
setTimeout(() => cleanupSSH(timeoutId), 100);
|
||||
return;
|
||||
}
|
||||
@@ -1327,11 +1321,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
function setupPingInterval() {
|
||||
// More frequent keepalive to prevent idle disconnections
|
||||
pingInterval = setInterval(() => {
|
||||
if (sshConn && sshStream) {
|
||||
try {
|
||||
// Send null byte as keepalive
|
||||
sshStream.write("\x00");
|
||||
} catch (e: unknown) {
|
||||
sshLogger.error(
|
||||
@@ -1341,12 +1333,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
cleanupSSH();
|
||||
}
|
||||
} else if (!sshConn || !sshStream) {
|
||||
// If connection or stream is lost, clear the interval
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
}
|
||||
}, 30000); // Reduced from 60s to 30s for more reliable keepalive
|
||||
}, 30000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,7 +29,10 @@ export function execCommand(
|
||||
});
|
||||
}
|
||||
|
||||
export function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
||||
export function toFixedNum(
|
||||
n: number | null | undefined,
|
||||
digits = 2,
|
||||
): number | null {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
}
|
||||
|
||||
@@ -36,9 +36,12 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
if (parts.length >= 10) {
|
||||
const user = parts[0];
|
||||
const tty = parts[1];
|
||||
const ip = parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
|
||||
const ip =
|
||||
parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
|
||||
|
||||
const timeStart = parts.indexOf(parts.find(p => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "");
|
||||
const timeStart = parts.indexOf(
|
||||
parts.find((p) => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "",
|
||||
);
|
||||
if (timeStart > 0 && parts.length > timeStart + 4) {
|
||||
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
|
||||
|
||||
@@ -96,7 +99,9 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
failedLogins.push({
|
||||
user,
|
||||
ip,
|
||||
time: timeStr ? new Date(timeStr).toISOString() : new Date().toISOString(),
|
||||
time: timeStr
|
||||
? new Date(timeStr).toISOString()
|
||||
: new Date().toISOString(),
|
||||
status: "failed",
|
||||
});
|
||||
if (ip !== "unknown") {
|
||||
|
||||
@@ -24,10 +24,7 @@ export async function collectProcessesMetrics(client: Client): Promise<{
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const psOut = await execCommand(
|
||||
client,
|
||||
"ps aux --sort=-%cpu | head -n 11",
|
||||
);
|
||||
const psOut = await execCommand(client, "ps aux --sort=-%cpu | head -n 11");
|
||||
const psLines = psOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
@@ -48,10 +45,7 @@ export async function collectProcessesMetrics(client: Client): Promise<{
|
||||
}
|
||||
|
||||
const procCount = await execCommand(client, "ps aux | wc -l");
|
||||
const runningCount = await execCommand(
|
||||
client,
|
||||
"ps aux | grep -c ' R '",
|
||||
);
|
||||
const runningCount = await execCommand(client, "ps aux | grep -c ' R '");
|
||||
totalProcesses = Number(procCount.stdout.trim()) - 1;
|
||||
runningProcesses = Number(runningCount.stdout.trim());
|
||||
} catch (e) {
|
||||
|
||||
@@ -98,8 +98,8 @@ class AuthManager {
|
||||
): Promise<boolean> {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
: 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const authenticated = await this.userCrypto.authenticateOIDCUser(
|
||||
userId,
|
||||
@@ -120,8 +120,8 @@ class AuthManager {
|
||||
): Promise<boolean> {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
: 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const authenticated = await this.userCrypto.authenticateUser(
|
||||
userId,
|
||||
@@ -136,6 +136,10 @@ class AuthManager {
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
async convertToOIDCEncryption(userId: string): Promise<void> {
|
||||
await this.userCrypto.convertToOIDCEncryption(userId);
|
||||
}
|
||||
|
||||
private async performLazyEncryptionMigration(userId: string): Promise<void> {
|
||||
try {
|
||||
const userDataKey = this.getUserDataKey(userId);
|
||||
|
||||
@@ -9,31 +9,34 @@ class LoginRateLimiter {
|
||||
private usernameAttempts = new Map<string, LoginAttempt>();
|
||||
|
||||
private readonly MAX_ATTEMPTS = 5;
|
||||
private readonly WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
private readonly LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
private readonly WINDOW_MS = 10 * 60 * 1000;
|
||||
private readonly LOCKOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
// Clean up old entries periodically
|
||||
constructor() {
|
||||
setInterval(() => this.cleanup(), 5 * 60 * 1000); // Clean every 5 minutes
|
||||
setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Clean IP attempts
|
||||
for (const [ip, attempt] of this.ipAttempts.entries()) {
|
||||
if (attempt.lockedUntil && attempt.lockedUntil < now) {
|
||||
this.ipAttempts.delete(ip);
|
||||
} else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) {
|
||||
} else if (
|
||||
!attempt.lockedUntil &&
|
||||
now - attempt.firstAttempt > this.WINDOW_MS
|
||||
) {
|
||||
this.ipAttempts.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean username attempts
|
||||
for (const [username, attempt] of this.usernameAttempts.entries()) {
|
||||
if (attempt.lockedUntil && attempt.lockedUntil < now) {
|
||||
this.usernameAttempts.delete(username);
|
||||
} else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) {
|
||||
} else if (
|
||||
!attempt.lockedUntil &&
|
||||
now - attempt.firstAttempt > this.WINDOW_MS
|
||||
) {
|
||||
this.usernameAttempts.delete(username);
|
||||
}
|
||||
}
|
||||
@@ -42,15 +45,13 @@ class LoginRateLimiter {
|
||||
recordFailedAttempt(ip: string, username?: string): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Record IP attempt
|
||||
const ipAttempt = this.ipAttempts.get(ip);
|
||||
if (!ipAttempt) {
|
||||
this.ipAttempts.set(ip, {
|
||||
count: 1,
|
||||
firstAttempt: now,
|
||||
});
|
||||
} else if ((now - ipAttempt.firstAttempt) > this.WINDOW_MS) {
|
||||
// Reset if outside window
|
||||
} else if (now - ipAttempt.firstAttempt > this.WINDOW_MS) {
|
||||
this.ipAttempts.set(ip, {
|
||||
count: 1,
|
||||
firstAttempt: now,
|
||||
@@ -62,7 +63,6 @@ class LoginRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
// Record username attempt if provided
|
||||
if (username) {
|
||||
const userAttempt = this.usernameAttempts.get(username);
|
||||
if (!userAttempt) {
|
||||
@@ -70,8 +70,7 @@ class LoginRateLimiter {
|
||||
count: 1,
|
||||
firstAttempt: now,
|
||||
});
|
||||
} else if ((now - userAttempt.firstAttempt) > this.WINDOW_MS) {
|
||||
// Reset if outside window
|
||||
} else if (now - userAttempt.firstAttempt > this.WINDOW_MS) {
|
||||
this.usernameAttempts.set(username, {
|
||||
count: 1,
|
||||
firstAttempt: now,
|
||||
@@ -92,10 +91,12 @@ class LoginRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
isLocked(ip: string, username?: string): { locked: boolean; remainingTime?: number } {
|
||||
isLocked(
|
||||
ip: string,
|
||||
username?: string,
|
||||
): { locked: boolean; remainingTime?: number } {
|
||||
const now = Date.now();
|
||||
|
||||
// Check IP lockout
|
||||
const ipAttempt = this.ipAttempts.get(ip);
|
||||
if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) {
|
||||
return {
|
||||
@@ -104,7 +105,6 @@ class LoginRateLimiter {
|
||||
};
|
||||
}
|
||||
|
||||
// Check username lockout
|
||||
if (username) {
|
||||
const userAttempt = this.usernameAttempts.get(username);
|
||||
if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) {
|
||||
@@ -122,18 +122,19 @@ class LoginRateLimiter {
|
||||
const now = Date.now();
|
||||
let minRemaining = this.MAX_ATTEMPTS;
|
||||
|
||||
// Check IP attempts
|
||||
const ipAttempt = this.ipAttempts.get(ip);
|
||||
if (ipAttempt && (now - ipAttempt.firstAttempt) <= this.WINDOW_MS) {
|
||||
if (ipAttempt && now - ipAttempt.firstAttempt <= this.WINDOW_MS) {
|
||||
const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count);
|
||||
minRemaining = Math.min(minRemaining, ipRemaining);
|
||||
}
|
||||
|
||||
// Check username attempts
|
||||
if (username) {
|
||||
const userAttempt = this.usernameAttempts.get(username);
|
||||
if (userAttempt && (now - userAttempt.firstAttempt) <= this.WINDOW_MS) {
|
||||
const userRemaining = Math.max(0, this.MAX_ATTEMPTS - userAttempt.count);
|
||||
if (userAttempt && now - userAttempt.firstAttempt <= this.WINDOW_MS) {
|
||||
const userRemaining = Math.max(
|
||||
0,
|
||||
this.MAX_ATTEMPTS - userAttempt.count,
|
||||
);
|
||||
minRemaining = Math.min(minRemaining, userRemaining);
|
||||
}
|
||||
}
|
||||
@@ -142,5 +143,4 @@ class LoginRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const loginRateLimiter = new LoginRateLimiter();
|
||||
|
||||
@@ -344,7 +344,6 @@ class UserCrypto {
|
||||
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
|
||||
const existingKEKSalt = await this.getKEKSalt(userId);
|
||||
|
||||
// If no existing encryption, nothing to convert
|
||||
if (!existingEncryptedDEK && !existingKEKSalt) {
|
||||
databaseLogger.info("No existing encryption to convert for user", {
|
||||
operation: "convert_to_oidc_encryption_skip",
|
||||
@@ -353,11 +352,9 @@ class UserCrypto {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get the DEK from active session
|
||||
const existingDEK = this.getUserDataKey(userId);
|
||||
|
||||
if (existingDEK) {
|
||||
// User has active session - preserve their data by re-encrypting DEK
|
||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||
const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
||||
systemKey.fill(0);
|
||||
@@ -372,8 +369,6 @@ class UserCrypto {
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// No active session - delete old encryption keys
|
||||
// OIDC will create new keys on next login, but old encrypted data will be inaccessible
|
||||
if (existingEncryptedDEK) {
|
||||
await getDb()
|
||||
.delete(settings)
|
||||
@@ -389,7 +384,6 @@ class UserCrypto {
|
||||
);
|
||||
}
|
||||
|
||||
// Always remove password-based KEK salt
|
||||
if (existingKEKSalt) {
|
||||
await getDb()
|
||||
.delete(settings)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -22,7 +22,7 @@ function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
export { Kbd, KbdGroup };
|
||||
|
||||
@@ -60,7 +60,7 @@ function TabsContent({
|
||||
"data-[state=active]:animate-in data-[state=inactive]:animate-out",
|
||||
"data-[state=inactive]:fade-out-0 data-[state=active]:fade-in-0",
|
||||
"duration-150",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -617,7 +617,6 @@ export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||
},
|
||||
};
|
||||
|
||||
// Font families available for terminal
|
||||
export const TERMINAL_FONTS = [
|
||||
{
|
||||
value: "Caskaydia Cove Nerd Font Mono",
|
||||
|
||||
@@ -329,12 +329,11 @@ export interface TabContextTab {
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
// Split Screen Layout Types
|
||||
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
|
||||
|
||||
export interface SplitConfiguration {
|
||||
layout: SplitLayout;
|
||||
positions: Map<number, number>; // position index -> tab ID
|
||||
positions: Map<number, number>;
|
||||
}
|
||||
|
||||
export interface SplitLayoutOption {
|
||||
|
||||
@@ -44,7 +44,7 @@ function AppContent() {
|
||||
const now = Date.now();
|
||||
if (now - lastShiftPressTime.current < 300) {
|
||||
setIsCommandPaletteOpen((isOpen) => !isOpen);
|
||||
lastShiftPressTime.current = 0; // Reset on double press
|
||||
lastShiftPressTime.current = 0;
|
||||
} else {
|
||||
lastShiftPressTime.current = now;
|
||||
}
|
||||
@@ -63,14 +63,12 @@ function AppContent() {
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
setAuthLoading(true);
|
||||
// Don't optimistically set isAuthenticated before checking
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
if (typeof meRes === "string" || !meRes.username) {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
// Clear invalid token
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
@@ -83,7 +81,6 @@ function AppContent() {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
// Clear invalid token on any auth error
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
|
||||
@@ -1533,7 +1533,6 @@ export function AdminSettings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link OIDC to Password Account Dialog */}
|
||||
{linkAccountAlertOpen && (
|
||||
<Dialog
|
||||
open={linkAccountAlertOpen}
|
||||
|
||||
@@ -63,7 +63,6 @@ export function CredentialEditor({
|
||||
useState(false);
|
||||
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Clear error when tab changes
|
||||
useEffect(() => {
|
||||
setFormError(null);
|
||||
}, [activeTab]);
|
||||
|
||||
@@ -102,7 +102,7 @@ export function Dashboard({
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const rightMarginPx = 17; // Base margin when closed
|
||||
const rightMarginPx = 17;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -213,7 +213,6 @@ export function Dashboard({
|
||||
statsConfig?: string | { metricsEnabled?: boolean };
|
||||
}) => {
|
||||
try {
|
||||
// Parse statsConfig if it's a string
|
||||
let statsConfig: { metricsEnabled?: boolean } = {
|
||||
metricsEnabled: true,
|
||||
};
|
||||
@@ -225,7 +224,6 @@ export function Dashboard({
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if metrics are disabled
|
||||
if (statsConfig.metricsEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1087,7 +1087,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
|
||||
);
|
||||
|
||||
// Refresh directory to show extracted files
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
@@ -1132,7 +1131,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Refresh directory to show compressed file
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -261,7 +261,6 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
// Add extract option for archive files
|
||||
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
|
||||
const fileName = files[0].name.toLowerCase();
|
||||
const isArchive =
|
||||
@@ -288,7 +287,6 @@ export function FileManagerContextMenu({
|
||||
}
|
||||
}
|
||||
|
||||
// Add compress option for selected files/folders
|
||||
if (isFileContext && onCompress) {
|
||||
menuItems.push({
|
||||
icon: <FileArchive className="w-4 h-4" />,
|
||||
|
||||
@@ -38,7 +38,6 @@ export function CompressDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (open && fileNames.length > 0) {
|
||||
// Generate default archive name
|
||||
if (fileNames.length === 1) {
|
||||
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
|
||||
setArchiveName(baseName);
|
||||
@@ -51,7 +50,6 @@ export function CompressDialog({
|
||||
const handleCompress = () => {
|
||||
if (!archiveName.trim()) return;
|
||||
|
||||
// Append extension if not already present
|
||||
let finalName = archiveName.trim();
|
||||
const extensions: Record<string, string> = {
|
||||
zip: ".zip",
|
||||
|
||||
@@ -30,7 +30,6 @@ interface PermissionsDialogProps {
|
||||
onSave: (file: FileItem, permissions: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// Parse permissions like "rwxr-xr-x" or "755" to individual bits
|
||||
const parsePermissions = (
|
||||
perms: string,
|
||||
): { owner: number; group: number; other: number } => {
|
||||
@@ -38,7 +37,6 @@ const parsePermissions = (
|
||||
return { owner: 0, group: 0, other: 0 };
|
||||
}
|
||||
|
||||
// If numeric format like "755"
|
||||
if (/^\d{3,4}$/.test(perms)) {
|
||||
const numStr = perms.slice(-3);
|
||||
return {
|
||||
@@ -47,8 +45,6 @@ const parsePermissions = (
|
||||
other: parseInt(numStr[2] || "0", 10),
|
||||
};
|
||||
}
|
||||
|
||||
// If symbolic format like "rwxr-xr-x" or "-rwxr-xr-x"
|
||||
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
|
||||
|
||||
const calcBits = (str: string): number => {
|
||||
@@ -66,7 +62,6 @@ const parsePermissions = (
|
||||
};
|
||||
};
|
||||
|
||||
// Convert individual bits to numeric format
|
||||
const toNumeric = (owner: number, group: number, other: number): string => {
|
||||
return `${owner}${group}${other}`;
|
||||
};
|
||||
@@ -99,7 +94,6 @@ export function PermissionsDialog({
|
||||
(initialPerms.other & 1) !== 0,
|
||||
);
|
||||
|
||||
// Reset when file changes
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const perms = parsePermissions(file.permissions || "644");
|
||||
|
||||
@@ -72,7 +72,6 @@ export function HostManager({
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
// Only clear editing state when leaving the respective tabs, not when entering them
|
||||
if (activeTab === "add_host" && value !== "add_host") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
|
||||
@@ -348,7 +348,6 @@ export function HostManagerEditor({
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Clear error when tab changes
|
||||
useEffect(() => {
|
||||
setFormError(null);
|
||||
}, [activeTab]);
|
||||
@@ -948,7 +947,6 @@ export function HostManagerEditor({
|
||||
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
|
||||
// Notify the stats server to start/update polling for this specific host
|
||||
if (savedHost?.id) {
|
||||
const { notifyHostCreatedOrUpdated } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
@@ -963,11 +961,9 @@ export function HostManagerEditor({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form validation errors
|
||||
const handleFormError = () => {
|
||||
const errors = form.formState.errors;
|
||||
|
||||
// Determine which tab contains the error
|
||||
if (
|
||||
errors.ip ||
|
||||
errors.port ||
|
||||
@@ -1088,7 +1084,6 @@ export function HostManagerEditor({
|
||||
|
||||
let filtered = sshConfigurations;
|
||||
|
||||
// Filter out the current host being edited (by ID, not by name)
|
||||
if (currentHostId) {
|
||||
const currentHostName = hosts.find((h) => h.id === currentHostId)?.name;
|
||||
if (currentHostName) {
|
||||
@@ -1097,7 +1092,6 @@ export function HostManagerEditor({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If creating a new host, filter by the name being entered
|
||||
const currentHostName =
|
||||
form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`;
|
||||
filtered = sshConfigurations.filter(
|
||||
|
||||
@@ -114,7 +114,6 @@ export function Server({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
// Reset state when switching to a different host
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setMetricsHistory([]);
|
||||
|
||||
@@ -25,9 +25,7 @@ interface LoginStatsWidgetProps {
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function LoginStatsWidget({
|
||||
metrics,
|
||||
}: LoginStatsWidgetProps) {
|
||||
export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loginStats = metrics?.login_stats;
|
||||
@@ -52,7 +50,9 @@ export function LoginStatsWidget({
|
||||
<Activity className="h-3 w-3" />
|
||||
<span>{t("serverStats.totalLogins")}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-green-400">{totalLogins}</div>
|
||||
<div className="text-xl font-bold text-green-400">
|
||||
{totalLogins}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
|
||||
@@ -86,7 +86,9 @@ export function LoginStatsWidget({
|
||||
<span className="text-green-400 font-mono truncate">
|
||||
{login.user}
|
||||
</span>
|
||||
<span className="text-gray-500">{t("serverStats.from")}</span>
|
||||
<span className="text-gray-500">
|
||||
{t("serverStats.from")}
|
||||
</span>
|
||||
<span className="text-blue-400 font-mono truncate">
|
||||
{login.ip}
|
||||
</span>
|
||||
@@ -118,7 +120,9 @@ export function LoginStatsWidget({
|
||||
<span className="text-red-400 font-mono truncate">
|
||||
{login.user}
|
||||
</span>
|
||||
<span className="text-gray-500">{t("serverStats.from")}</span>
|
||||
<span className="text-gray-500">
|
||||
{t("serverStats.from")}
|
||||
</span>
|
||||
<span className="text-blue-400 font-mono truncate">
|
||||
{login.ip}
|
||||
</span>
|
||||
|
||||
@@ -131,13 +131,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const activityLoggedRef = useRef(false);
|
||||
const keyHandlerAttachedRef = useRef(false);
|
||||
|
||||
// Command history tracking (Stage 1)
|
||||
const { trackInput, getCurrentCommand, updateCurrentCommand } =
|
||||
useCommandTracker({
|
||||
hostId: hostConfig.id,
|
||||
enabled: true,
|
||||
onCommandExecuted: (command) => {
|
||||
// Add to autocomplete history (Stage 3)
|
||||
if (!autocompleteHistory.current.includes(command)) {
|
||||
autocompleteHistory.current = [
|
||||
command,
|
||||
@@ -147,7 +145,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
},
|
||||
});
|
||||
|
||||
// Create refs for callbacks to avoid triggering useEffect re-runs
|
||||
const getCurrentCommandRef = useRef(getCurrentCommand);
|
||||
const updateCurrentCommandRef = useRef(updateCurrentCommand);
|
||||
|
||||
@@ -156,7 +153,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
updateCurrentCommandRef.current = updateCurrentCommand;
|
||||
}, [getCurrentCommand, updateCurrentCommand]);
|
||||
|
||||
// Real-time autocomplete (Stage 3)
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<
|
||||
string[]
|
||||
@@ -170,23 +166,19 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const autocompleteHistory = useRef<string[]>([]);
|
||||
const currentAutocompleteCommand = useRef<string>("");
|
||||
|
||||
// Refs for accessing current state in event handlers
|
||||
const showAutocompleteRef = useRef(false);
|
||||
const autocompleteSuggestionsRef = useRef<string[]>([]);
|
||||
const autocompleteSelectedIndexRef = useRef(0);
|
||||
|
||||
// Command history dialog (Stage 2)
|
||||
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
|
||||
// Create refs for context methods to avoid infinite loops
|
||||
const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading);
|
||||
const setCommandHistoryContextRef = useRef(
|
||||
commandHistoryContext.setCommandHistory,
|
||||
);
|
||||
|
||||
// Keep refs updated with latest context methods
|
||||
useEffect(() => {
|
||||
setIsLoadingRef.current = commandHistoryContext.setIsLoading;
|
||||
setCommandHistoryContextRef.current =
|
||||
@@ -196,7 +188,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
commandHistoryContext.setCommandHistory,
|
||||
]);
|
||||
|
||||
// Load command history when dialog opens
|
||||
useEffect(() => {
|
||||
if (showHistoryDialog && hostConfig.id) {
|
||||
setIsLoadingHistory(true);
|
||||
@@ -219,9 +210,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}, [showHistoryDialog, hostConfig.id]);
|
||||
|
||||
// Load command history for autocomplete on mount (Stage 3)
|
||||
useEffect(() => {
|
||||
// Check if command autocomplete is enabled
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") !== "false";
|
||||
|
||||
@@ -240,7 +229,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}, [hostConfig.id]);
|
||||
|
||||
// Sync autocomplete state to refs for event handlers
|
||||
useEffect(() => {
|
||||
showAutocompleteRef.current = showAutocomplete;
|
||||
}, [showAutocomplete]);
|
||||
@@ -642,9 +630,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}),
|
||||
);
|
||||
terminal.onData((data) => {
|
||||
// Track command input for history (Stage 1)
|
||||
trackInput(data);
|
||||
// Send input to server
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
@@ -904,20 +890,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle command selection from history dialog (Stage 2)
|
||||
const handleSelectCommand = useCallback(
|
||||
(command: string) => {
|
||||
if (!terminal || !webSocketRef.current) return;
|
||||
|
||||
// Send the command to the terminal
|
||||
// Simulate typing the command character by character
|
||||
for (const char of command) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: char }),
|
||||
);
|
||||
}
|
||||
|
||||
// Return focus to terminal after selecting command
|
||||
setTimeout(() => {
|
||||
terminal.focus();
|
||||
}, 100);
|
||||
@@ -925,12 +907,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
[terminal],
|
||||
);
|
||||
|
||||
// Register handlers with context
|
||||
useEffect(() => {
|
||||
commandHistoryContext.setOnSelectCommand(handleSelectCommand);
|
||||
}, [handleSelectCommand]);
|
||||
|
||||
// Handle autocomplete selection (mouse click)
|
||||
const handleAutocompleteSelect = useCallback(
|
||||
(selectedCommand: string) => {
|
||||
if (!webSocketRef.current) return;
|
||||
@@ -938,22 +918,18 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const currentCmd = currentAutocompleteCommand.current;
|
||||
const completion = selectedCommand.substring(currentCmd.length);
|
||||
|
||||
// Send completion characters to server
|
||||
for (const char of completion) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: char }),
|
||||
);
|
||||
}
|
||||
|
||||
// Update current command tracker
|
||||
updateCurrentCommand(selectedCommand);
|
||||
|
||||
// Close autocomplete
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteSuggestions([]);
|
||||
currentAutocompleteCommand.current = "";
|
||||
|
||||
// Return focus to terminal
|
||||
setTimeout(() => {
|
||||
terminal?.focus();
|
||||
}, 50);
|
||||
@@ -963,26 +939,22 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
[terminal, updateCurrentCommand],
|
||||
);
|
||||
|
||||
// Handle command deletion from history dialog
|
||||
const handleDeleteCommand = useCallback(
|
||||
async (command: string) => {
|
||||
if (!hostConfig.id) return;
|
||||
|
||||
try {
|
||||
// Call API to delete command
|
||||
const { deleteCommandFromHistory } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
);
|
||||
await deleteCommandFromHistory(hostConfig.id, command);
|
||||
|
||||
// Update local state
|
||||
setCommandHistory((prev) => {
|
||||
const newHistory = prev.filter((cmd) => cmd !== command);
|
||||
setCommandHistoryContextRef.current(newHistory);
|
||||
return newHistory;
|
||||
});
|
||||
|
||||
// Update autocomplete history
|
||||
autocompleteHistory.current = autocompleteHistory.current.filter(
|
||||
(cmd) => cmd !== command,
|
||||
);
|
||||
@@ -995,7 +967,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
[hostConfig.id],
|
||||
);
|
||||
|
||||
// Register delete handler with context
|
||||
useEffect(() => {
|
||||
commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
|
||||
}, [handleDeleteCommand]);
|
||||
@@ -1104,7 +1075,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
||||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
// Handle Ctrl+R for command history (Stage 2)
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
e.key === "r" &&
|
||||
@@ -1115,7 +1085,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowHistoryDialog(true);
|
||||
// Also trigger the sidebar to open
|
||||
if (commandHistoryContext.openCommandHistory) {
|
||||
commandHistoryContext.openCommandHistory();
|
||||
}
|
||||
@@ -1210,20 +1179,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
|
||||
// Register keyboard handler for autocomplete (Stage 3)
|
||||
// Registered only once when terminal is created
|
||||
useEffect(() => {
|
||||
if (!terminal) return;
|
||||
|
||||
const handleCustomKey = (e: KeyboardEvent): boolean => {
|
||||
// Only handle keydown events, ignore keyup to prevent double triggering
|
||||
if (e.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If autocomplete is showing, handle keys specially
|
||||
if (showAutocompleteRef.current) {
|
||||
// Handle Escape to close autocomplete
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1233,7 +1197,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle Arrow keys for autocomplete navigation
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1253,7 +1216,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle Enter to confirm autocomplete selection
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
autocompleteSuggestionsRef.current.length > 0
|
||||
@@ -1268,7 +1230,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const currentCmd = currentAutocompleteCommand.current;
|
||||
const completion = selectedCommand.substring(currentCmd.length);
|
||||
|
||||
// Send completion characters to server
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
for (const char of completion) {
|
||||
webSocketRef.current.send(
|
||||
@@ -1277,10 +1238,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Update current command tracker
|
||||
updateCurrentCommandRef.current(selectedCommand);
|
||||
|
||||
// Close autocomplete
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteSuggestions([]);
|
||||
currentAutocompleteCommand.current = "";
|
||||
@@ -1288,7 +1247,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle Tab to cycle through suggestions
|
||||
if (
|
||||
e.key === "Tab" &&
|
||||
!e.ctrlKey &&
|
||||
@@ -1306,14 +1264,12 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return false;
|
||||
}
|
||||
|
||||
// For any other key while autocomplete is showing, close it and let key through
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteSuggestions([]);
|
||||
currentAutocompleteCommand.current = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle Tab for autocomplete (when autocomplete is not showing)
|
||||
if (
|
||||
e.key === "Tab" &&
|
||||
!e.ctrlKey &&
|
||||
@@ -1324,12 +1280,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if command autocomplete is enabled in settings
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") !== "false";
|
||||
|
||||
if (!autocompleteEnabled) {
|
||||
// If disabled, let the terminal handle Tab normally (send to server)
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: "\t" }),
|
||||
@@ -1340,7 +1294,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const currentCmd = getCurrentCommandRef.current().trim();
|
||||
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
|
||||
// Filter commands that start with current input
|
||||
const matches = autocompleteHistory.current
|
||||
.filter(
|
||||
(cmd) =>
|
||||
@@ -1348,10 +1301,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
cmd !== currentCmd &&
|
||||
cmd.length > currentCmd.length,
|
||||
)
|
||||
.slice(0, 5); // Show up to 5 matches for better UX
|
||||
.slice(0, 5);
|
||||
|
||||
if (matches.length === 1) {
|
||||
// Only one match - auto-complete directly
|
||||
const completedCommand = matches[0];
|
||||
const completion = completedCommand.substring(currentCmd.length);
|
||||
|
||||
@@ -1363,12 +1315,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
updateCurrentCommandRef.current(completedCommand);
|
||||
} else if (matches.length > 1) {
|
||||
// Multiple matches - show selection list
|
||||
currentAutocompleteCommand.current = currentCmd;
|
||||
setAutocompleteSuggestions(matches);
|
||||
setAutocompleteSelectedIndex(0);
|
||||
|
||||
// Calculate position (below or above cursor based on available space)
|
||||
const cursorY = terminal.buffer.active.cursorY;
|
||||
const cursorX = terminal.buffer.active.cursorX;
|
||||
const rect = xtermRef.current?.getBoundingClientRect();
|
||||
@@ -1379,8 +1329,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const cellWidth =
|
||||
terminal.cols > 0 ? rect.width / terminal.cols : 10;
|
||||
|
||||
// Calculate actual menu height based on number of items
|
||||
// Each item is ~32px (py-1.5), footer is ~32px, max total 240px
|
||||
const itemHeight = 32;
|
||||
const footerHeight = 32;
|
||||
const maxMenuHeight = 240;
|
||||
@@ -1388,14 +1336,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
matches.length * itemHeight + footerHeight,
|
||||
maxMenuHeight,
|
||||
);
|
||||
|
||||
// Get cursor position in viewport coordinates
|
||||
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
|
||||
const cursorTopY = rect.top + cursorY * cellHeight;
|
||||
const spaceBelow = window.innerHeight - cursorBottomY;
|
||||
const spaceAbove = cursorTopY;
|
||||
|
||||
// Show above cursor if not enough space below and more space above
|
||||
const showAbove =
|
||||
spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
@@ -1410,10 +1355,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setShowAutocomplete(true);
|
||||
}
|
||||
}
|
||||
return false; // Prevent default Tab behavior
|
||||
return false;
|
||||
}
|
||||
|
||||
// Let terminal handle all other keys
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1470,7 +1414,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't set isFitted to false - keep terminal visible during resize
|
||||
let rafId1: number;
|
||||
let rafId2: number;
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export function CommandAutocomplete({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && containerRef.current) {
|
||||
selectedRef.current.scrollIntoView({
|
||||
@@ -33,8 +32,6 @@ export function CommandAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate max height for suggestions list to ensure footer is always visible
|
||||
// Footer height is approximately 32px (text + padding + border)
|
||||
const footerHeight = 32;
|
||||
const maxSuggestionsHeight = 240 - footerHeight;
|
||||
|
||||
@@ -62,9 +59,7 @@ export function CommandAutocomplete({
|
||||
index === selectedIndex && "bg-gray-500/20 text-gray-400",
|
||||
)}
|
||||
onClick={() => onSelect(suggestion)}
|
||||
onMouseEnter={() => {
|
||||
// Optional: update selected index on hover
|
||||
}}
|
||||
onMouseEnter={() => {}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
|
||||
@@ -105,14 +105,12 @@ export function SSHToolsSidebar({
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
|
||||
|
||||
// Update active tab when initialTab changes
|
||||
useEffect(() => {
|
||||
if (initialTab && isOpen) {
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
}, [initialTab, isOpen]);
|
||||
|
||||
// Call onTabChange when active tab changes
|
||||
const handleTabChange = (tab: string) => {
|
||||
setActiveTab(tab);
|
||||
if (onTabChange) {
|
||||
@@ -120,14 +118,12 @@ export function SSHToolsSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
// SSH Tools state
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
const [rightClickCopyPaste, setRightClickCopyPaste] = useState<boolean>(
|
||||
() => getCookie("rightClickCopyPaste") === "true",
|
||||
);
|
||||
|
||||
// Snippets state
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
@@ -145,14 +141,12 @@ export function SSHToolsSidebar({
|
||||
[],
|
||||
);
|
||||
|
||||
// Command History state
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
|
||||
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Split Screen state
|
||||
const [splitMode, setSplitMode] = useState<"none" | "2" | "3" | "4">("none");
|
||||
const [splitAssignments, setSplitAssignments] = useState<Map<number, number>>(
|
||||
new Map(),
|
||||
@@ -163,7 +157,6 @@ export function SSHToolsSidebar({
|
||||
null,
|
||||
);
|
||||
|
||||
// Resize state
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const startXRef = React.useRef<number | null>(null);
|
||||
const startWidthRef = React.useRef<number>(sidebarWidth);
|
||||
@@ -174,7 +167,6 @@ export function SSHToolsSidebar({
|
||||
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
|
||||
const activeTerminalHostId = activeTerminal?.hostConfig?.id;
|
||||
|
||||
// Get splittable tabs (terminal, server, file_manager)
|
||||
const splittableTabs = tabs.filter(
|
||||
(tab: TabData) =>
|
||||
tab.type === "terminal" ||
|
||||
@@ -183,20 +175,16 @@ export function SSHToolsSidebar({
|
||||
tab.type === "user_profile",
|
||||
);
|
||||
|
||||
// Fetch command history
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === "command-history") {
|
||||
if (activeTerminalHostId) {
|
||||
// Save current scroll position before any state updates
|
||||
const scrollTop = commandHistoryScrollRef.current?.scrollTop || 0;
|
||||
|
||||
getCommandHistory(activeTerminalHostId)
|
||||
.then((history) => {
|
||||
setCommandHistory((prevHistory) => {
|
||||
const newHistory = Array.isArray(history) ? history : [];
|
||||
// Only update if history actually changed
|
||||
if (JSON.stringify(prevHistory) !== JSON.stringify(newHistory)) {
|
||||
// Use requestAnimationFrame to restore scroll after React finishes rendering
|
||||
requestAnimationFrame(() => {
|
||||
if (commandHistoryScrollRef.current) {
|
||||
commandHistoryScrollRef.current.scrollTop = scrollTop;
|
||||
@@ -223,7 +211,6 @@ export function SSHToolsSidebar({
|
||||
historyRefreshCounter,
|
||||
]);
|
||||
|
||||
// Auto-refresh command history every 2 seconds when history tab is active
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === "command-history" && activeTerminalHostId) {
|
||||
const refreshInterval = setInterval(() => {
|
||||
@@ -234,14 +221,12 @@ export function SSHToolsSidebar({
|
||||
}
|
||||
}, [isOpen, activeTab, activeTerminalHostId]);
|
||||
|
||||
// Filter command history based on search query
|
||||
const filteredCommands = searchQuery
|
||||
? commandHistory.filter((cmd) =>
|
||||
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: commandHistory;
|
||||
|
||||
// Initialize CSS variable on mount and when sidebar width changes
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--right-sidebar-width",
|
||||
@@ -249,7 +234,6 @@ export function SSHToolsSidebar({
|
||||
);
|
||||
}, [sidebarWidth]);
|
||||
|
||||
// Handle window resize to adjust sidebar width
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
|
||||
@@ -270,7 +254,6 @@ export function SSHToolsSidebar({
|
||||
}
|
||||
}, [isOpen, activeTab]);
|
||||
|
||||
// Resize handlers
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
@@ -283,7 +266,7 @@ export function SSHToolsSidebar({
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (startXRef.current == null) return;
|
||||
const dx = startXRef.current - e.clientX; // Reversed because we're on the right
|
||||
const dx = startXRef.current - e.clientX;
|
||||
const newWidth = Math.round(startWidthRef.current + dx);
|
||||
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
|
||||
const maxWidth = Math.round(window.innerWidth * 0.3);
|
||||
@@ -295,13 +278,11 @@ export function SSHToolsSidebar({
|
||||
finalWidth = maxWidth;
|
||||
}
|
||||
|
||||
// Update CSS variable immediately for smooth animation
|
||||
document.documentElement.style.setProperty(
|
||||
"--right-sidebar-width",
|
||||
`${finalWidth}px`,
|
||||
);
|
||||
|
||||
// Update React state (this will be batched/debounced naturally)
|
||||
setSidebarWidth(finalWidth);
|
||||
};
|
||||
|
||||
@@ -323,7 +304,6 @@ export function SSHToolsSidebar({
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
// SSH Tools handlers
|
||||
const handleTabToggle = (tabId: number) => {
|
||||
setSelectedTabIds((prev) =>
|
||||
prev.includes(tabId)
|
||||
@@ -487,7 +467,6 @@ export function SSHToolsSidebar({
|
||||
setRightClickCopyPaste(checked);
|
||||
};
|
||||
|
||||
// Snippets handlers
|
||||
const fetchSnippets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -599,15 +578,12 @@ export function SSHToolsSidebar({
|
||||
toast.success(t("snippets.copySuccess", { name: snippet.name }));
|
||||
};
|
||||
|
||||
// Split Screen handlers
|
||||
const handleSplitModeChange = (mode: "none" | "2" | "3" | "4") => {
|
||||
setSplitMode(mode);
|
||||
|
||||
if (mode === "none") {
|
||||
// Clear all splits
|
||||
handleClearSplit();
|
||||
} else {
|
||||
// Clear assignments when changing modes
|
||||
setSplitAssignments(new Map());
|
||||
setPreviewKey((prev) => prev + 1);
|
||||
}
|
||||
@@ -636,7 +612,6 @@ export function SSHToolsSidebar({
|
||||
|
||||
setSplitAssignments((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
// Remove this tab from any other cell
|
||||
Array.from(newMap.entries()).forEach(([idx, id]) => {
|
||||
if (id === draggedTabId && idx !== cellIndex) {
|
||||
newMap.delete(idx);
|
||||
@@ -677,7 +652,6 @@ export function SSHToolsSidebar({
|
||||
|
||||
const requiredSlots = parseInt(splitMode);
|
||||
|
||||
// Validate: All layout spots must be filled
|
||||
if (splitAssignments.size < requiredSlots) {
|
||||
toast.error(
|
||||
t("splitScreen.error.fillAllSlots", {
|
||||
@@ -688,7 +662,6 @@ export function SSHToolsSidebar({
|
||||
return;
|
||||
}
|
||||
|
||||
// Build ordered array of tab IDs based on cell index (0, 1, 2, 3)
|
||||
const orderedTabIds: number[] = [];
|
||||
for (let i = 0; i < requiredSlots; i++) {
|
||||
const tabId = splitAssignments.get(i);
|
||||
@@ -697,18 +670,15 @@ export function SSHToolsSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
// First, clear ALL existing splits
|
||||
const currentSplits = [...allSplitScreenTab];
|
||||
currentSplits.forEach((tabId) => {
|
||||
setSplitScreenTab(tabId); // Toggle off
|
||||
setSplitScreenTab(tabId);
|
||||
});
|
||||
|
||||
// Then, add only the newly assigned tabs to split IN ORDER
|
||||
orderedTabIds.forEach((tabId) => {
|
||||
setSplitScreenTab(tabId); // Toggle on
|
||||
setSplitScreenTab(tabId);
|
||||
});
|
||||
|
||||
// Set first assigned tab as active if current tab is not in split
|
||||
if (!orderedTabIds.includes(currentTab ?? 0)) {
|
||||
setCurrentTab(orderedTabIds[0]);
|
||||
}
|
||||
@@ -721,7 +691,6 @@ export function SSHToolsSidebar({
|
||||
};
|
||||
|
||||
const handleClearSplit = () => {
|
||||
// Remove all tabs from split screen
|
||||
allSplitScreenTab.forEach((tabId) => {
|
||||
setSplitScreenTab(tabId);
|
||||
});
|
||||
@@ -741,7 +710,6 @@ export function SSHToolsSidebar({
|
||||
handleClearSplit();
|
||||
};
|
||||
|
||||
// Command History handlers
|
||||
const handleCommandSelect = (command: string) => {
|
||||
if (activeTerminal?.terminalRef?.current?.sendInput) {
|
||||
activeTerminal.terminalRef.current.sendInput(command);
|
||||
|
||||
@@ -26,7 +26,6 @@ export function ElectronLoginForm({
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear any existing token to prevent login loops with expired tokens
|
||||
localStorage.removeItem("jwt");
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -111,7 +111,6 @@ export function AppView({
|
||||
}, [updatePanelRects, fitActiveAndNotify]);
|
||||
|
||||
const hideThenFit = React.useCallback(() => {
|
||||
// Don't hide terminals, just fit them immediately
|
||||
requestAnimationFrame(() => {
|
||||
updatePanelRects();
|
||||
requestAnimationFrame(() => {
|
||||
@@ -204,7 +203,6 @@ export function AppView({
|
||||
|
||||
const renderTerminalsLayer = () => {
|
||||
const styles: Record<number, React.CSSProperties> = {};
|
||||
// Use allSplitScreenTab order directly - it maintains the order tabs were added
|
||||
const layoutTabs = allSplitScreenTab
|
||||
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
|
||||
.filter((t): t is TabData => t !== null && t !== undefined);
|
||||
@@ -259,10 +257,8 @@ export function AppView({
|
||||
const isVisible =
|
||||
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
|
||||
|
||||
// Use previous style if available to maintain position
|
||||
const previousStyle = previousStylesRef.current[t.id];
|
||||
|
||||
// For non-split screen tabs, always use the standard position
|
||||
const isFileManagerTab = t.type === "file_manager";
|
||||
const standardStyle = {
|
||||
position: "absolute" as const,
|
||||
@@ -354,7 +350,6 @@ export function AppView({
|
||||
};
|
||||
|
||||
const renderSplitOverlays = () => {
|
||||
// Use allSplitScreenTab order directly - it maintains the order tabs were added
|
||||
const layoutTabs = allSplitScreenTab
|
||||
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
|
||||
.filter((t): t is TabData => t !== null && t !== undefined);
|
||||
|
||||
@@ -99,7 +99,6 @@ export function TopNavbar({
|
||||
setCommandHistoryTabActive(true);
|
||||
}, []);
|
||||
|
||||
// Register function to open command history sidebar
|
||||
React.useEffect(() => {
|
||||
commandHistory.setOpenCommandHistory(openCommandHistorySidebar);
|
||||
}, [commandHistory, openCommandHistorySidebar]);
|
||||
@@ -133,11 +132,9 @@ export function TopNavbar({
|
||||
};
|
||||
|
||||
const handleTabSplit = (tabId: number) => {
|
||||
// Open the sidebar to the split-screen tab
|
||||
setToolsSidebarOpen(true);
|
||||
setCommandHistoryTabActive(false);
|
||||
setSplitScreenTabActive(true);
|
||||
// Optional: could pass tabId to pre-select this tab in the sidebar
|
||||
};
|
||||
|
||||
const handleTabClose = (tabId: number) => {
|
||||
|
||||
@@ -35,20 +35,17 @@ export function ElectronVersionCheck({
|
||||
const updateInfo = await checkElectronUpdate();
|
||||
setVersionInfo(updateInfo);
|
||||
|
||||
// Get current app version
|
||||
const currentVersion = await (window as any).electronAPI?.getAppVersion();
|
||||
const dismissedVersion = localStorage.getItem(
|
||||
"electron-version-check-dismissed",
|
||||
);
|
||||
|
||||
// If this version was already dismissed, skip the modal
|
||||
if (dismissedVersion === currentVersion) {
|
||||
onContinue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateInfo?.status === "up_to_date") {
|
||||
// Store this version as checked (but don't show modal since up to date)
|
||||
if (currentVersion) {
|
||||
localStorage.setItem(
|
||||
"electron-version-check-dismissed",
|
||||
@@ -73,7 +70,6 @@ export function ElectronVersionCheck({
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
// Store the current version as dismissed
|
||||
const currentVersion = await (window as any).electronAPI?.getAppVersion();
|
||||
if (currentVersion) {
|
||||
localStorage.setItem("electron-version-check-dismissed", currentVersion);
|
||||
|
||||
@@ -144,7 +144,6 @@ export function UserProfile({
|
||||
const handleFileColorCodingToggle = (enabled: boolean) => {
|
||||
setFileColorCoding(enabled);
|
||||
localStorage.setItem("fileColorCoding", enabled.toString());
|
||||
// Trigger a re-render by dispatching a custom event
|
||||
window.dispatchEvent(new Event("fileColorCodingChanged"));
|
||||
};
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@ interface CommandHistoryResult {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing command history and autocomplete suggestions
|
||||
*/
|
||||
export function useCommandHistory({
|
||||
hostId,
|
||||
enabled = true,
|
||||
@@ -25,8 +22,7 @@ export function useCommandHistory({
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const historyCache = useRef<Map<number, string[]>>(new Map());
|
||||
|
||||
// Fetch command history when hostId changes
|
||||
s;
|
||||
useEffect(() => {
|
||||
if (!enabled || !hostId) {
|
||||
setCommandHistory([]);
|
||||
@@ -34,14 +30,12 @@ export function useCommandHistory({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = historyCache.current.get(hostId);
|
||||
if (cached) {
|
||||
setCommandHistory(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch from server
|
||||
const fetchHistory = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -59,9 +53,6 @@ export function useCommandHistory({
|
||||
fetchHistory();
|
||||
}, [hostId, enabled]);
|
||||
|
||||
/**
|
||||
* Get command suggestions based on current input
|
||||
*/
|
||||
const getSuggestions = useCallback(
|
||||
(input: string): string[] => {
|
||||
if (!input || input.trim().length === 0) {
|
||||
@@ -73,7 +64,6 @@ export function useCommandHistory({
|
||||
cmd.startsWith(trimmedInput),
|
||||
);
|
||||
|
||||
// Return up to 10 suggestions, excluding exact matches
|
||||
const filtered = matches
|
||||
.filter((cmd) => cmd !== trimmedInput)
|
||||
.slice(0, 10);
|
||||
@@ -84,9 +74,6 @@ export function useCommandHistory({
|
||||
[commandHistory],
|
||||
);
|
||||
|
||||
/**
|
||||
* Save a command to history
|
||||
*/
|
||||
const saveCommand = useCallback(
|
||||
async (command: string) => {
|
||||
if (!enabled || !hostId || !command || command.trim().length === 0) {
|
||||
@@ -95,29 +82,24 @@ export function useCommandHistory({
|
||||
|
||||
const trimmedCommand = command.trim();
|
||||
|
||||
// Skip if it's the same as the last command
|
||||
if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Save to server
|
||||
await saveCommandToHistory(hostId, trimmedCommand);
|
||||
|
||||
// Update local state - add to beginning
|
||||
setCommandHistory((prev) => {
|
||||
const newHistory = [
|
||||
trimmedCommand,
|
||||
...prev.filter((c) => c !== trimmedCommand),
|
||||
];
|
||||
// Keep max 500 commands in memory
|
||||
const limited = newHistory.slice(0, 500);
|
||||
historyCache.current.set(hostId, limited);
|
||||
return limited;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save command to history:", error);
|
||||
// Still update local state even if server save fails
|
||||
setCommandHistory((prev) => {
|
||||
const newHistory = [
|
||||
trimmedCommand,
|
||||
@@ -130,9 +112,6 @@ export function useCommandHistory({
|
||||
[enabled, hostId, commandHistory],
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear current suggestions
|
||||
*/
|
||||
const clearSuggestions = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
}, []);
|
||||
|
||||
@@ -14,10 +14,6 @@ interface CommandTrackerResult {
|
||||
updateCurrentCommand: (command: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to track terminal input and save executed commands to history
|
||||
* Works with SSH terminals by monitoring input data
|
||||
*/
|
||||
export function useCommandTracker({
|
||||
hostId,
|
||||
enabled = true,
|
||||
@@ -26,33 +22,25 @@ export function useCommandTracker({
|
||||
const currentCommandRef = useRef<string>("");
|
||||
const isInEscapeSequenceRef = useRef<boolean>(false);
|
||||
|
||||
/**
|
||||
* Track input data and detect command execution
|
||||
*/
|
||||
const trackInput = useCallback(
|
||||
(data: string) => {
|
||||
if (!enabled || !hostId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle each character
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data[i];
|
||||
const charCode = char.charCodeAt(0);
|
||||
|
||||
// Detect escape sequences (e.g., arrow keys, function keys)
|
||||
if (charCode === 27) {
|
||||
// ESC
|
||||
isInEscapeSequenceRef.current = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip characters that are part of escape sequences
|
||||
if (isInEscapeSequenceRef.current) {
|
||||
// Common escape sequence endings
|
||||
if (
|
||||
(charCode >= 65 && charCode <= 90) || // A-Z
|
||||
(charCode >= 97 && charCode <= 122) || // a-z
|
||||
(charCode >= 65 && charCode <= 90) ||
|
||||
(charCode >= 97 && charCode <= 122) ||
|
||||
charCode === 126 // ~
|
||||
) {
|
||||
isInEscapeSequenceRef.current = false;
|
||||
@@ -60,53 +48,41 @@ export function useCommandTracker({
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle Enter key (CR or LF)
|
||||
if (charCode === 13 || charCode === 10) {
|
||||
// \r or \n
|
||||
const command = currentCommandRef.current.trim();
|
||||
|
||||
// Save non-empty commands
|
||||
if (command.length > 0) {
|
||||
// Save to history (async, don't wait)
|
||||
saveCommandToHistory(hostId, command).catch((error) => {
|
||||
console.error("Failed to save command to history:", error);
|
||||
});
|
||||
|
||||
// Callback for external handling
|
||||
if (onCommandExecuted) {
|
||||
onCommandExecuted(command);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear current command
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle Backspace/Delete
|
||||
if (charCode === 8 || charCode === 127) {
|
||||
// Backspace or DEL
|
||||
if (currentCommandRef.current.length > 0) {
|
||||
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle Ctrl+C, Ctrl+D, etc. - clear current command
|
||||
if (charCode === 3 || charCode === 4) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle Ctrl+U (clear line) - common in terminals
|
||||
if (charCode === 21) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add printable characters to current command
|
||||
if (charCode >= 32 && charCode <= 126) {
|
||||
// Printable ASCII
|
||||
currentCommandRef.current += char;
|
||||
}
|
||||
}
|
||||
@@ -114,23 +90,14 @@ export function useCommandTracker({
|
||||
[enabled, hostId, onCommandExecuted],
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the current command being typed
|
||||
*/
|
||||
const getCurrentCommand = useCallback(() => {
|
||||
return currentCommandRef.current;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear the current command buffer
|
||||
*/
|
||||
const clearCurrentCommand = useCallback(() => {
|
||||
currentCommandRef.current = "";
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update the current command buffer (used for autocomplete)
|
||||
*/
|
||||
const updateCurrentCommand = useCallback((command: string) => {
|
||||
currentCommandRef.current = command;
|
||||
}, []);
|
||||
|
||||
@@ -138,10 +138,8 @@ function getLoggerForService(serviceName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for Electron settings
|
||||
const electronSettingsCache = new Map<string, string>();
|
||||
|
||||
// Load settings from Electron IPC on startup
|
||||
if (isElectron()) {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -153,13 +151,12 @@ if (isElectron()) {
|
||||
).electronAPI;
|
||||
|
||||
if (electronAPI?.getSetting) {
|
||||
// Preload common settings
|
||||
const settingsToLoad = ["rightClickCopyPaste", "jwt"];
|
||||
for (const key of settingsToLoad) {
|
||||
const value = await electronAPI.getSetting(key);
|
||||
if (value !== null && value !== undefined) {
|
||||
electronSettingsCache.set(key, value);
|
||||
localStorage.setItem(key, value); // Sync to localStorage
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,13 +169,10 @@ if (isElectron()) {
|
||||
export function setCookie(name: string, value: string, days = 7): void {
|
||||
if (isElectron()) {
|
||||
try {
|
||||
// Update cache
|
||||
electronSettingsCache.set(name, value);
|
||||
|
||||
// Set in localStorage
|
||||
localStorage.setItem(name, value);
|
||||
|
||||
// Persist to file system via Electron IPC
|
||||
const electronAPI = (
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
@@ -205,12 +199,10 @@ export function setCookie(name: string, value: string, days = 7): void {
|
||||
export function getCookie(name: string): string | undefined {
|
||||
if (isElectron()) {
|
||||
try {
|
||||
// Try cache first
|
||||
if (electronSettingsCache.has(name)) {
|
||||
return electronSettingsCache.get(name);
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
const token = localStorage.getItem(name) || undefined;
|
||||
if (token) {
|
||||
electronSettingsCache.set(name, token);
|
||||
@@ -397,22 +389,17 @@ function createApiInstance(
|
||||
errorMessage === "Authentication required";
|
||||
|
||||
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
|
||||
// Clear token from localStorage
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
// Clear Electron settings cache
|
||||
if (isElectron()) {
|
||||
electronSettingsCache.delete("jwt");
|
||||
}
|
||||
|
||||
// Clear cookie
|
||||
if (typeof window !== "undefined") {
|
||||
document.cookie =
|
||||
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
}
|
||||
|
||||
// Only show toast and reload for explicit session expiration
|
||||
// Let the auth check in DesktopApp.tsx handle the redirect silently
|
||||
if (isSessionExpired && typeof window !== "undefined") {
|
||||
console.warn("Session expired - please log in again");
|
||||
import("sonner").then(({ toast }) => {
|
||||
@@ -1737,7 +1724,6 @@ export async function compressSSHFiles(
|
||||
// FILE MANAGER DATA
|
||||
// ============================================================================
|
||||
|
||||
// Recent Files
|
||||
export async function getRecentFiles(
|
||||
hostId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
@@ -1916,7 +1902,6 @@ export async function refreshServerPolling(): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/refresh");
|
||||
} catch (error) {
|
||||
// Silently fail - this is a background operation
|
||||
console.warn("Failed to refresh server polling:", error);
|
||||
}
|
||||
}
|
||||
@@ -1927,7 +1912,6 @@ export async function notifyHostCreatedOrUpdated(
|
||||
try {
|
||||
await statsApi.post("/host-updated", { hostId });
|
||||
} catch (error) {
|
||||
// Silently fail - this is a background operation
|
||||
console.warn("Failed to notify stats server of host update:", error);
|
||||
}
|
||||
}
|
||||
@@ -2961,9 +2945,6 @@ export async function resetRecentActivity(): Promise<{ message: string }> {
|
||||
// COMMAND HISTORY API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Save a command to history for a specific host
|
||||
*/
|
||||
export async function saveCommandToHistory(
|
||||
hostId: number,
|
||||
command: string,
|
||||
@@ -2979,12 +2960,6 @@ export async function saveCommandToHistory(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get command history for a specific host
|
||||
* Returns array of unique commands ordered by most recent
|
||||
* @param hostId - The host ID to fetch history for
|
||||
* @param limit - Maximum number of commands to return (default: 100)
|
||||
*/
|
||||
export async function getCommandHistory(
|
||||
hostId: number,
|
||||
limit: number = 100,
|
||||
@@ -2999,9 +2974,6 @@ export async function getCommandHistory(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific command from history
|
||||
*/
|
||||
export async function deleteCommandFromHistory(
|
||||
hostId: number,
|
||||
command: string,
|
||||
@@ -3017,9 +2989,6 @@ export async function deleteCommandFromHistory(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear command history for a specific host (optional feature)
|
||||
*/
|
||||
export async function clearCommandHistory(
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
@@ -3037,9 +3006,6 @@ export async function clearCommandHistory(
|
||||
// OIDC ACCOUNT LINKING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Link an OIDC user to an existing password account (merges OIDC into password account)
|
||||
*/
|
||||
export async function linkOIDCToPasswordAccount(
|
||||
oidcUserId: string,
|
||||
targetUsername: string,
|
||||
|
||||
Reference in New Issue
Block a user