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