feat: Add 5 immersive loading animations and multiple feature enhancements #433

Merged
ZacharyZcR merged 42 commits from main into main 2025-11-10 02:41:20 +00:00
79 changed files with 8625 additions and 734 deletions

View File

@@ -746,7 +746,6 @@ jobs:
mkdir -p homebrew-submission/Casks/t
cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb
cp homebrew/README.md homebrew-submission/
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb

17
package-lock.json generated
View File

@@ -51,6 +51,7 @@
"chalk": "^4.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.0",
@@ -6936,6 +6937,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codem-isoboxer": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.10.tgz",

View File

@@ -70,6 +70,7 @@
"chalk": "^4.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.0",

View File

@@ -7,6 +7,7 @@ import sshRoutes from "./routes/ssh.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import terminalRoutes from "./routes/terminal.js";
import cors from "cors";
import fetch from "node-fetch";
import fs from "fs";
@@ -1418,6 +1419,7 @@ app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/terminal", terminalRoutes);
app.use(
(
@@ -1480,13 +1482,17 @@ app.get(
if (status.hasUnencryptedDb) {
try {
unencryptedSize = fs.statSync(dbPath).size;
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", { error });
}
}
if (status.hasEncryptedDb) {
try {
encryptedSize = fs.statSync(encryptedDbPath).size;
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", { error });
}
}
res.json({

View File

@@ -95,6 +95,32 @@ async function initializeDatabaseAsync(): Promise<void> {
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
});
try {
databaseLogger.info(
"Generating diagnostic information for database encryption failure",
{
operation: "db_encryption_diagnostic",
},
);
const diagnosticInfo =
DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath);
databaseLogger.error(
"Database encryption diagnostic completed - check logs above for details",
null,
{
operation: "db_encryption_diagnostic_completed",
filesConsistent: diagnosticInfo.validation.filesConsistent,
sizeMismatch: diagnosticInfo.validation.sizeMismatch,
},
);
} catch (diagError) {
databaseLogger.warn("Failed to generate diagnostic information", {
operation: "db_diagnostic_failed",
error:
diagError instanceof Error ? diagError.message : "Unknown error",
});
}
throw new Error(
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
);
@@ -274,6 +300,17 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS ssh_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS recent_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -285,6 +322,16 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
command TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
`);
try {

View File

@@ -209,6 +209,22 @@ export const snippets = sqliteTable("snippets", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const sshFolders = sqliteTable("ssh_folders", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const recentActivity = sqliteTable("recent_activity", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -223,3 +239,17 @@ export const recentActivity = sqliteTable("recent_activity", {
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const commandHistory = sqliteTable("command_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -8,6 +8,7 @@ import {
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
sshFolders,
} from "../db/schema.js";
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response } from "express";
@@ -1341,6 +1342,17 @@ router.put(
DatabaseSaveTrigger.triggerSave("folder_rename");
// Also update folder metadata if exists
await db
.update(sshFolders)
.set({
name: newName,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
);
res.json({
message: "Folder renamed successfully",
updatedHosts: updatedHosts.length,
@@ -1358,6 +1370,157 @@ router.put(
},
);
// Route: Get all folders with metadata (requires JWT)
// GET /ssh/db/folders
router.get(
"/folders",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid user ID" });
}
try {
const folders = await db
.select()
.from(sshFolders)
.where(eq(sshFolders.userId, userId));
res.json(folders);
} catch (err) {
sshLogger.error("Failed to fetch folders", err, {
operation: "fetch_folders",
userId,
});
res.status(500).json({ error: "Failed to fetch folders" });
}
},
);
// Route: Update folder metadata (requires JWT)
// PUT /ssh/db/folders/metadata
router.put(
"/folders/metadata",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
// Check if folder metadata exists
const existing = await db
.select()
.from(sshFolders)
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.limit(1);
if (existing.length > 0) {
// Update existing
await db
.update(sshFolders)
.set({
color,
icon,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
);
} else {
// Create new
await db.insert(sshFolders).values({
userId,
name,
color,
icon,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
res.json({ message: "Folder metadata updated successfully" });
} catch (err) {
sshLogger.error("Failed to update folder metadata", err, {
operation: "update_folder_metadata",
userId,
name,
});
res.status(500).json({ error: "Failed to update folder metadata" });
}
},
);
// Route: Delete all hosts in folder (requires JWT)
// DELETE /ssh/db/folders/:name/hosts
router.delete(
"/folders/:name/hosts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folderName = req.params.name;
if (!isNonEmptyString(userId) || !folderName) {
return res.status(400).json({ error: "Invalid folder name" });
}
try {
// Get all hosts in the folder
const hostsToDelete = await db
.select()
.from(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
if (hostsToDelete.length === 0) {
return res.json({
message: "No hosts found in folder",
deletedCount: 0,
});
}
// Delete all hosts
await db
.delete(sshData)
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
// Delete folder metadata
await db
.delete(sshFolders)
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
);
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
sshLogger.info("Deleted all hosts in folder", {
operation: "delete_folder_hosts",
userId,
folderName,
deletedCount: hostsToDelete.length,
});
res.json({
message: "All hosts in folder deleted successfully",
deletedCount: hostsToDelete.length,
});
} catch (err) {
sshLogger.error("Failed to delete hosts in folder", err, {
operation: "delete_folder_hosts",
userId,
folderName,
});
res.status(500).json({ error: "Failed to delete hosts in folder" });
}
},
);
// Route: Bulk import SSH hosts (requires JWT)
// POST /ssh/bulk-import
router.post(

View File

@@ -0,0 +1,221 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { commandHistory } from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Save command to history
// POST /terminal/command_history
router.post(
"/command_history",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (
!isNonEmptyString(userId) ||
!hostId ||
!isNonEmptyString(command)
) {
authLogger.warn("Invalid command history save request", {
operation: "command_history_save",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const insertData = {
userId,
hostId: parseInt(hostId, 10),
command: command.trim(),
};
const result = await db.insert(commandHistory).values(insertData).returning();
authLogger.info(`Command saved to history for host ${hostId}`, {
operation: "command_history_save_success",
userId,
hostId: parseInt(hostId, 10),
});
res.status(201).json(result[0]);
} catch (err) {
authLogger.error("Failed to save command to history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to save command",
});
}
},
);
// Get command history for a specific host
// GET /terminal/command_history/:hostId
router.get(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.params;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history fetch request", {
userId,
hostId: hostIdNum,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
// Get unique commands for this host, ordered by most recent
// Use DISTINCT to avoid duplicates, but keep the most recent occurrence
const result = await db
.selectDistinct({ command: commandHistory.command })
.from(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum)
)
)
.orderBy(desc(commandHistory.executedAt))
.limit(500); // Limit to last 500 unique commands
// Further deduplicate in case DISTINCT didn't work perfectly
const uniqueCommands = Array.from(
new Set(result.map((r) => r.command))
);
authLogger.info(`Fetched command history for host ${hostId}`, {
operation: "command_history_fetch_success",
userId,
hostId: hostIdNum,
count: uniqueCommands.length,
});
res.json(uniqueCommands);
} catch (err) {
authLogger.error("Failed to fetch command history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch history",
});
}
},
);
// Delete a specific command from history
// POST /terminal/command_history/delete
router.post(
"/command_history/delete",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
if (
!isNonEmptyString(userId) ||
!hostId ||
!isNonEmptyString(command)
) {
authLogger.warn("Invalid command delete request", {
operation: "command_history_delete",
userId,
hasHostId: !!hostId,
hasCommand: !!command,
});
return res.status(400).json({ error: "Missing required parameters" });
}
try {
const hostIdNum = parseInt(hostId, 10);
// Delete all instances of this command for this user and host
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum),
eq(commandHistory.command, command.trim())
)
);
authLogger.info(`Command deleted from history for host ${hostId}`, {
operation: "command_history_delete_success",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete command from history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete command",
});
}
},
);
// Clear command history for a specific host (optional feature)
// DELETE /terminal/command_history/:hostId
router.delete(
"/command_history/:hostId",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.params;
const hostIdNum = parseInt(hostId, 10);
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
authLogger.warn("Invalid command history clear request");
return res.status(400).json({ error: "Invalid request" });
}
try {
await db
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostIdNum)
)
);
authLogger.success(`Command history cleared for host ${hostId}`, {
operation: "command_history_clear_success",
userId,
hostId: hostIdNum,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to clear command history", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to clear history",
});
}
},
);
export default router;

View File

@@ -22,11 +22,12 @@ import { nanoid } from "nanoid";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
const authManager = AuthManager.getInstance();
@@ -862,6 +863,7 @@ router.get("/oidc/callback", async (req, res) => {
// POST /users/login
router.post("/login", async (req, res) => {
const { username, password } = req.body;
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
authLogger.warn("Invalid traditional login attempt", {
@@ -872,6 +874,21 @@ router.post("/login", async (req, res) => {
return res.status(400).json({ error: "Invalid username or password" });
}
// Check rate limiting
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
if (lockStatus.locked) {
authLogger.warn("Login attempt blocked due to rate limiting", {
operation: "user_login_blocked",
username,
ip: clientIp,
remainingTime: lockStatus.remainingTime,
});
return res.status(429).json({
error: "Too many login attempts. Please try again later.",
remainingTime: lockStatus.remainingTime,
});
}
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
@@ -896,11 +913,14 @@ router.post("/login", async (req, res) => {
.where(eq(users.username, username));
if (!user || user.length === 0) {
authLogger.warn(`User not found: ${username}`, {
loginRateLimiter.recordFailedAttempt(clientIp, username);
authLogger.warn(`Login failed: user not found`, {
operation: "user_login",
username,
ip: clientIp,
remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username),
});
return res.status(404).json({ error: "User not found" });
return res.status(401).json({ error: "Invalid username or password" });
}
const userRecord = user[0];
@@ -918,12 +938,15 @@ router.post("/login", async (req, res) => {
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) {
authLogger.warn(`Incorrect password for user: ${username}`, {
loginRateLimiter.recordFailedAttempt(clientIp, username);
authLogger.warn(`Login failed: incorrect password`, {
operation: "user_login",
username,
userId: userRecord.id,
ip: clientIp,
remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username),
});
return res.status(401).json({ error: "Incorrect password" });
return res.status(401).json({ error: "Invalid username or password" });
}
try {
@@ -935,7 +958,9 @@ router.post("/login", async (req, res) => {
if (kekSalt.length === 0) {
await authManager.registerUser(userRecord.id, password);
}
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", { error });
}
const dataUnlocked = await authManager.authenticateUser(
userRecord.id,
@@ -963,6 +988,9 @@ router.post("/login", async (req, res) => {
deviceInfo: deviceInfo.deviceInfo,
});
// Reset rate limiter on successful login
loginRateLimiter.resetAttempts(clientIp, username);
authLogger.success(`User logged in successfully: ${username}`, {
operation: "user_login_success",
username,
@@ -970,6 +998,7 @@ router.post("/login", async (req, res) => {
dataUnlocked: true,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
ip: clientIp,
});
const response: Record<string, unknown> = {
@@ -1016,7 +1045,15 @@ router.post("/logout", authenticateJWT, async (req, res) => {
try {
const payload = await authManager.verifyJWTToken(token);
sessionId = payload?.sessionId;
} catch (error) {}
} catch (error) {
authLogger.debug(
"Token verification failed during logout (expected if token expired)",
{
operation: "logout_token_verify_failed",
userId,
},
);
}
}
await authManager.logoutUser(userId, sessionId);

View File

@@ -6,7 +6,7 @@ import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshCredentials, sshData } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { fileLogger } from "../utils/logger.js";
import { fileLogger, sshLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../types/index.js";
@@ -120,7 +120,9 @@ function cleanupSession(sessionId: string) {
if (session) {
try {
session.client.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
@@ -663,7 +665,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
fileLogger.warn("TOTP session timeout before code submission", {
operation: "file_totp_verify",
sessionId,
@@ -2486,6 +2490,421 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
});
});
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for changePermissions",
{
operation: "change_permissions",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!path) {
return res.status(400).json({ error: "File path is required" });
}
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
return res.status(400).json({
error: "Valid permissions required (e.g., 755, 644)"
});
}
const octalPerms = permissions.slice(-3);
const escapedPath = path.replace(/'/g, "'\"'\"'");
const command = `chmod ${octalPerms} '${escapedPath}'`;
fileLogger.info("Changing file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
sshConn.client.exec(command, (err, stream) => {
if (err) {
fileLogger.error("SSH changePermissions exec error:", err, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
return res.status(500).json({ error: "Failed to change permissions" });
}
let errorOutput = "";
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
fileLogger.error("chmod command failed", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
exitCode: code,
error: errorOutput,
});
return res.status(500).json({
error: errorOutput || "Failed to change permissions"
});
}
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
res.json({
success: true,
message: "Permissions changed successfully"
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH changePermissions stream error:", streamErr, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res.status(500).json({ error: "Stream error while changing permissions" });
}
});
});
});
// Route: Extract archive file (requires JWT)
// POST /ssh/file_manager/ssh/extractArchive
app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
const { sessionId, archivePath, extractPath } = req.body;
if (!sessionId || !archivePath) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const fileName = archivePath.split("/").pop() || "";
const fileExt = fileName.toLowerCase();
// Determine extraction command based on file extension
let extractCommand = "";
const targetPath = extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) {
extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) {
extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".tar.xz")) {
extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".tar")) {
extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`;
} else if (fileExt.endsWith(".zip")) {
extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`;
} else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) {
extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`;
} else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) {
extractCommand = `bunzip2 -k "${archivePath}"`;
} else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) {
extractCommand = `unxz -k "${archivePath}"`;
} else if (fileExt.endsWith(".7z")) {
extractCommand = `7z x "${archivePath}" -o"${targetPath}"`;
} else if (fileExt.endsWith(".rar")) {
extractCommand = `unrar x "${archivePath}" "${targetPath}/"`;
} else {
return res.status(400).json({ error: "Unsupported archive format" });
}
fileLogger.info("Extracting archive", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
command: extractCommand,
});
session.client.exec(extractCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during extract:", err, {
operation: "extract_archive",
sessionId,
archivePath,
});
return res.status(500).json({ error: "Failed to execute extract command" });
}
let errorOutput = "";
stream.on("data", (data: Buffer) => {
fileLogger.debug("Extract stdout", {
operation: "extract_archive",
sessionId,
output: data.toString(),
});
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
fileLogger.debug("Extract stderr", {
operation: "extract_archive",
sessionId,
error: data.toString(),
});
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Extract command failed", {
operation: "extract_archive",
sessionId,
archivePath,
exitCode: code,
error: errorOutput,
});
// Check if command not found
let friendlyError = errorOutput || "Failed to extract archive";
if (errorOutput.includes("command not found") || errorOutput.includes("not found")) {
// Detect which command is missing based on file extension
let missingCmd = "";
let installHint = "";
if (fileExt.endsWith(".zip")) {
missingCmd = "unzip";
installHint = "apt install unzip / yum install unzip / brew install unzip";
} else if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz") ||
fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2") ||
fileExt.endsWith(".tar.xz") || fileExt.endsWith(".tar")) {
missingCmd = "tar";
installHint = "Usually pre-installed on Linux/Unix systems";
} else if (fileExt.endsWith(".gz")) {
missingCmd = "gunzip";
installHint = "apt install gzip / yum install gzip / Usually pre-installed";
} else if (fileExt.endsWith(".bz2")) {
missingCmd = "bunzip2";
installHint = "apt install bzip2 / yum install bzip2 / brew install bzip2";
} else if (fileExt.endsWith(".xz")) {
missingCmd = "unxz";
installHint = "apt install xz-utils / yum install xz / brew install xz";
} else if (fileExt.endsWith(".7z")) {
missingCmd = "7z";
installHint = "apt install p7zip-full / yum install p7zip / brew install p7zip";
} else if (fileExt.endsWith(".rar")) {
missingCmd = "unrar";
installHint = "apt install unrar / yum install unrar / brew install unrar";
}
if (missingCmd) {
friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
});
res.json({
success: true,
message: "Archive extracted successfully",
extractPath: targetPath
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH extractArchive stream error:", streamErr, {
operation: "extract_archive",
sessionId,
archivePath,
});
if (!res.headersSent) {
res.status(500).json({ error: "Stream error while extracting archive" });
}
});
});
});
// Route: Compress files/folders (requires JWT)
// POST /ssh/file_manager/ssh/compressFiles
app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
const { sessionId, paths, archiveName, format } = req.body;
if (!sessionId || !paths || !Array.isArray(paths) || paths.length === 0 || !archiveName) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Determine compression format
const compressionFormat = format || "zip"; // Default to zip
let compressCommand = "";
// Get the directory where the first file is located
const firstPath = paths[0];
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
// Extract just the file/folder names for the command
const fileNames = paths.map(p => {
const name = p.split("/").pop();
return `"${name}"`;
}).join(" ");
// Construct archive path
let archivePath = "";
if (archiveName.includes("/")) {
archivePath = archiveName;
} else {
archivePath = workingDir.endsWith("/")
? `${workingDir}${archiveName}`
: `${workingDir}/${archiveName}`;
}
if (compressionFormat === "zip") {
// Use zip command - need to cd to directory first
compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") {
compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar.xz") {
compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "tar") {
compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`;
} else if (compressionFormat === "7z") {
compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`;
} else {
return res.status(400).json({ error: "Unsupported compression format" });
}
fileLogger.info("Compressing files", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
command: compressCommand,
});
session.client.exec(compressCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during compress:", err, {
operation: "compress_files",
sessionId,
paths,
});
return res.status(500).json({ error: "Failed to execute compress command" });
}
let errorOutput = "";
stream.on("data", (data: Buffer) => {
fileLogger.debug("Compress stdout", {
operation: "compress_files",
sessionId,
output: data.toString(),
});
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
fileLogger.debug("Compress stderr", {
operation: "compress_files",
sessionId,
error: data.toString(),
});
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Compress command failed", {
operation: "compress_files",
sessionId,
paths,
archivePath,
exitCode: code,
error: errorOutput,
});
// Check if command not found
let friendlyError = errorOutput || "Failed to compress files";
if (errorOutput.includes("command not found") || errorOutput.includes("not found")) {
const commandMap: Record<string, { cmd: string; install: string }> = {
"zip": { cmd: "zip", install: "apt install zip / yum install zip / brew install zip" },
"tar.gz": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" },
"tar.bz2": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" },
"tar.xz": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" },
"tar": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" },
"7z": { cmd: "7z", install: "apt install p7zip-full / yum install p7zip / brew install p7zip" },
};
const info = commandMap[compressionFormat];
if (info) {
friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
});
res.json({
success: true,
message: "Files compressed successfully",
archivePath: archivePath
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH compressFiles stream error:", streamErr, {
operation: "compress_files",
sessionId,
paths,
});
if (!res.headersSent) {
res.status(500).json({ error: "Stream error while compressing files" });
}
});
});
});
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);

View File

@@ -6,10 +6,18 @@ import { Client, type ConnectConfig } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { statsLogger } from "../utils/logger.js";
import { statsLogger, sshLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../types/index.js";
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
import { collectDiskMetrics } from "./widgets/disk-collector.js";
import { collectNetworkMetrics } from "./widgets/network-collector.js";
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
interface PooledConnection {
client: Client;
@@ -156,7 +164,9 @@ class SSHConnectionPool {
if (!conn.inUse && now - conn.lastUsed > maxAge) {
try {
conn.client.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
return false;
}
return true;
@@ -176,7 +186,9 @@ class SSHConnectionPool {
for (const conn of connections) {
try {
conn.client.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
}
}
this.connections.clear();
@@ -214,7 +226,9 @@ class RequestQueue {
if (request) {
try {
await request();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
}
}
@@ -511,7 +525,14 @@ class PollingManager {
data: metrics,
timestamp: Date.now(),
});
} catch (error) {}
} catch (error) {
statsLogger.warn("Failed to collect metrics for host", {
operation: "metrics_poll_failed",
hostId: host.id,
hostName: host.name,
error: error instanceof Error ? error.message : String(error),
});
}
}
stopPollingForHost(hostId: number): void {
@@ -911,59 +932,6 @@ async function withSshConnection<T>(
}
}
function execCommand(
client: Client,
command: string,
): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
client.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
exitCode = typeof code === "number" ? code : null;
resolve({ stdout, stderr, code: exitCode });
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
});
});
});
}
function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
cpu: {
percent: number | null;
@@ -1026,298 +994,38 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
return requestQueue.queueRequest(host.id, async () => {
try {
return await withSshConnection(host, async (client) => {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
const cpu = await collectCpuMetrics(client);
const memory = await collectMemoryMetrics(client);
const disk = await collectDiskMetrics(client);
const network = await collectNetworkMetrics(client);
const uptime = await collectUptimeMetrics(client);
const processes = await collectProcessesMetrics(client);
const system = await collectSystemMetrics(client);
let login_stats = {
recentLogins: [],
failedLogins: [],
totalLogins: 0,
uniqueIPs: 0,
};
try {
const [stat1, loadAvgOut, coresOut] = await Promise.all([
execCommand(client, "cat /proc/stat"),
execCommand(client, "cat /proc/loadavg"),
execCommand(
client,
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
),
]);
await new Promise((r) => setTimeout(r, 500));
const stat2 = await execCommand(client, "cat /proc/stat");
const cpuLine1 = (
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const cpuLine2 = (
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2);
if (a && b) {
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0)
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) {
loadTriplet = [
Number(laParts[0]),
Number(laParts[1]),
Number(laParts[2]),
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
number,
number,
number,
];
}
const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
login_stats = await collectLoginStats(client);
} catch (e) {
cpuPercent = null;
cores = null;
loadTriplet = null;
statsLogger.debug("Failed to collect login stats", {
operation: "login_stats_failed",
error: e instanceof Error ? e.message : String(e),
});
}
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const memInfo = await execCommand(client, "cat /proc/meminfo");
const lines = memInfo.stdout.split("\n");
const getVal = (key: string) => {
const line = lines.find((l) => l.startsWith(key));
if (!line) return null;
const m = line.match(/\d+/);
return m ? Number(m[0]) : null;
};
const totalKb = getVal("MemTotal:");
const availKb = getVal("MemAvailable:");
if (totalKb && availKb && totalKb > 0) {
const usedKb = totalKb - availKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch (e) {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
let availableHuman: string | null = null;
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -h -P / | tail -n +2"),
execCommand(client, "df -B1 -P / | tail -n +2"),
]);
const humanLine =
diskOutHuman.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const bytesLine =
diskOutBytes.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const humanParts = humanLine.split(/\s+/);
const bytesParts = bytesLine.split(/\s+/);
if (humanParts.length >= 6 && bytesParts.length >= 6) {
totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null;
availableHuman = humanParts[3] || null;
const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]);
if (
Number.isFinite(totalBytes) &&
Number.isFinite(usedBytes) &&
totalBytes > 0
) {
diskPercent = Math.max(
0,
Math.min(100, (usedBytes / totalBytes) * 100),
);
}
}
} catch (e) {
diskPercent = null;
usedHuman = null;
totalHuman = null;
availableHuman = null;
}
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
}> = [];
try {
const ifconfigOut = await execCommand(
client,
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
);
const netStatOut = await execCommand(
client,
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
);
const addrs = ifconfigOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const states = netStatOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const ifMap = new Map<string, { ip: string; state: string }>();
for (const line of addrs) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const ip = parts[1].split("/")[0];
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
}
}
for (const line of states) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const state = parts[1];
const existing = ifMap.get(name);
if (existing) {
existing.state = state;
}
}
}
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
});
}
} catch (e) {}
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const uptimeOut = await execCommand(client, "cat /proc/uptime");
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
if (uptimeParts.length >= 1) {
uptimeSeconds = Number(uptimeParts[0]);
if (Number.isFinite(uptimeSeconds)) {
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
}
}
} catch (e) {}
let totalProcesses: number | null = null;
let runningProcesses: number | null = null;
const topProcesses: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}> = [];
try {
const psOut = await execCommand(
client,
"ps aux --sort=-%cpu | head -n 11",
);
const psLines = psOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (psLines.length > 1) {
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
const parts = psLines[i].split(/\s+/);
if (parts.length >= 11) {
topProcesses.push({
pid: parts[1],
user: parts[0],
cpu: parts[2],
mem: parts[3],
command: parts.slice(10).join(" ").substring(0, 50),
});
}
}
}
const procCount = await execCommand(client, "ps aux | wc -l");
const runningCount = await execCommand(
client,
"ps aux | grep -c ' R '",
);
totalProcesses = Number(procCount.stdout.trim()) - 1;
runningProcesses = Number(runningCount.stdout.trim());
} catch (e) {}
let hostname: string | null = null;
let kernel: string | null = null;
let os: string | null = null;
try {
const hostnameOut = await execCommand(client, "hostname");
const kernelOut = await execCommand(client, "uname -r");
const osOut = await execCommand(
client,
"cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2",
);
hostname = hostnameOut.stdout.trim() || null;
kernel = kernelOut.stdout.trim() || null;
os = osOut.stdout.trim() || null;
} catch (e) {}
const result = {
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
memory: {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
},
disk: {
percent: toFixedNum(diskPercent, 0),
usedHuman,
totalHuman,
availableHuman,
},
network: {
interfaces,
},
uptime: {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
},
processes: {
total: totalProcesses,
running: runningProcesses,
top: topProcesses,
},
system: {
hostname,
kernel,
os,
},
cpu,
memory,
disk,
network,
uptime,
processes,
system,
login_stats,
};
metricsCache.set(host.id, result);
@@ -1365,7 +1073,9 @@ function tcpPing(
settled = true;
try {
socket.destroy();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
resolve(result);
};

View File

@@ -15,7 +15,7 @@ import type {
ErrorType,
} from "../../types/index.js";
import { CONNECTION_STATES } from "../../types/index.js";
import { tunnelLogger } from "../utils/logger.js";
import { tunnelLogger, sshLogger } from "../utils/logger.js";
import { SystemCrypto } from "../utils/system-crypto.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { DataCrypto } from "../utils/data-crypto.js";
@@ -217,7 +217,9 @@ function cleanupTunnelResources(
if (verification?.timeout) clearTimeout(verification.timeout);
try {
verification?.conn.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
tunnelVerifications.delete(tunnelName);
}
@@ -282,7 +284,9 @@ function handleDisconnect(
const verification = tunnelVerifications.get(tunnelName);
if (verification?.timeout) clearTimeout(verification.timeout);
verification?.conn.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
tunnelVerifications.delete(tunnelName);
}
@@ -638,7 +642,9 @@ async function connectSSHTunnel(
try {
conn.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
activeTunnels.delete(tunnelName);
@@ -778,7 +784,9 @@ async function connectSSHTunnel(
const verification = tunnelVerifications.get(tunnelName);
if (verification?.timeout) clearTimeout(verification.timeout);
verification?.conn.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
tunnelVerifications.delete(tunnelName);
}

View File

@@ -0,0 +1,39 @@
import type { Client } from "ssh2";
export function execCommand(
client: Client,
command: string,
): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
client.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
exitCode = typeof code === "number" ? code : null;
resolve({ stdout, stderr, code: exitCode });
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
});
});
});
}
export function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
export function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}

View File

@@ -0,0 +1,83 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
export async function collectCpuMetrics(client: Client): Promise<{
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
}> {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
try {
const [stat1, loadAvgOut, coresOut] = await Promise.all([
execCommand(client, "cat /proc/stat"),
execCommand(client, "cat /proc/loadavg"),
execCommand(
client,
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
),
]);
await new Promise((r) => setTimeout(r, 500));
const stat2 = await execCommand(client, "cat /proc/stat");
const cpuLine1 = (
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const cpuLine2 = (
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2);
if (a && b) {
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0)
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) {
loadTriplet = [
Number(laParts[0]),
Number(laParts[1]),
Number(laParts[2]),
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
number,
number,
number,
];
}
const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch (e) {
cpuPercent = null;
cores = null;
loadTriplet = null;
}
return {
percent: toFixedNum(cpuPercent, 0),
cores,
load: loadTriplet,
};
}

View File

@@ -0,0 +1,67 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
export async function collectDiskMetrics(client: Client): Promise<{
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
}> {
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
let availableHuman: string | null = null;
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -h -P / | tail -n +2"),
execCommand(client, "df -B1 -P / | tail -n +2"),
]);
const humanLine =
diskOutHuman.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const bytesLine =
diskOutBytes.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const humanParts = humanLine.split(/\s+/);
const bytesParts = bytesLine.split(/\s+/);
if (humanParts.length >= 6 && bytesParts.length >= 6) {
totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null;
availableHuman = humanParts[3] || null;
const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]);
if (
Number.isFinite(totalBytes) &&
Number.isFinite(usedBytes) &&
totalBytes > 0
) {
diskPercent = Math.max(
0,
Math.min(100, (usedBytes / totalBytes) * 100),
);
}
}
} catch (e) {
diskPercent = null;
usedHuman = null;
totalHuman = null;
availableHuman = null;
}
return {
percent: toFixedNum(diskPercent, 0),
usedHuman,
totalHuman,
availableHuman,
};
}

View File

@@ -0,0 +1,117 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
export interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
export interface LoginStats {
recentLogins: LoginRecord[];
failedLogins: LoginRecord[];
totalLogins: number;
uniqueIPs: number;
}
export async function collectLoginStats(client: Client): Promise<LoginStats> {
const recentLogins: LoginRecord[] = [];
const failedLogins: LoginRecord[] = [];
const ipSet = new Set<string>();
try {
const lastOut = await execCommand(
client,
"last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20",
);
const lastLines = lastOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const line of lastLines) {
const parts = line.split(/\s+/);
if (parts.length >= 10) {
const user = parts[0];
const tty = parts[1];
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)) || "");
if (timeStart > 0 && parts.length > timeStart + 4) {
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
if (user && user !== "wtmp" && tty !== "system") {
recentLogins.push({
user,
ip,
time: new Date(timeStr).toISOString(),
status: "success",
});
if (ip !== "local") {
ipSet.add(ip);
}
}
}
}
}
} catch (e) {
// Ignore errors
}
try {
const failedOut = await execCommand(
client,
"grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''",
);
const failedLines = failedOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const line of failedLines) {
let user = "unknown";
let ip = "unknown";
let timeStr = "";
const userMatch = line.match(/for (?:invalid user )?(\S+)/);
if (userMatch) {
user = userMatch[1];
}
const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/);
if (ipMatch) {
ip = ipMatch[1];
}
const dateMatch = line.match(/^(\w+\s+\d+\s+\d+:\d+:\d+)/);
if (dateMatch) {
const currentYear = new Date().getFullYear();
timeStr = `${currentYear} ${dateMatch[1]}`;
}
if (user && ip) {
failedLogins.push({
user,
ip,
time: timeStr ? new Date(timeStr).toISOString() : new Date().toISOString(),
status: "failed",
});
if (ip !== "unknown") {
ipSet.add(ip);
}
}
}
} catch (e) {
// Ignore errors
}
return {
recentLogins: recentLogins.slice(0, 10),
failedLogins: failedLogins.slice(0, 10),
totalLogins: recentLogins.length,
uniqueIPs: ipSet.size,
};
}

View File

@@ -0,0 +1,41 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum, kibToGiB } from "./common-utils.js";
export async function collectMemoryMetrics(client: Client): Promise<{
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}> {
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const memInfo = await execCommand(client, "cat /proc/meminfo");
const lines = memInfo.stdout.split("\n");
const getVal = (key: string) => {
const line = lines.find((l) => l.startsWith(key));
if (!line) return null;
const m = line.match(/\d+/);
return m ? Number(m[0]) : null;
};
const totalKb = getVal("MemTotal:");
const availKb = getVal("MemAvailable:");
if (totalKb && availKb && totalKb > 0) {
const usedKb = totalKb - availKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch (e) {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
return {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
};
}

View File

@@ -0,0 +1,79 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { statsLogger } from "../../utils/logger.js";
export async function collectNetworkMetrics(client: Client): Promise<{
interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
}>;
}> {
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
}> = [];
try {
const ifconfigOut = await execCommand(
client,
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
);
const netStatOut = await execCommand(
client,
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
);
const addrs = ifconfigOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const states = netStatOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const ifMap = new Map<string, { ip: string; state: string }>();
for (const line of addrs) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const ip = parts[1].split("/")[0];
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
}
}
for (const line of states) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const state = parts[1];
const existing = ifMap.get(name);
if (existing) {
existing.state = state;
}
}
}
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
});
}
} catch (e) {
statsLogger.debug("Failed to collect network interface stats", {
operation: "network_stats_failed",
error: e instanceof Error ? e.message : String(e),
});
}
return { interfaces };
}

View File

@@ -0,0 +1,69 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { statsLogger } from "../../utils/logger.js";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
running: number | null;
top: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}>;
}> {
let totalProcesses: number | null = null;
let runningProcesses: number | null = null;
const topProcesses: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}> = [];
try {
const psOut = await execCommand(
client,
"ps aux --sort=-%cpu | head -n 11",
);
const psLines = psOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (psLines.length > 1) {
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
const parts = psLines[i].split(/\s+/);
if (parts.length >= 11) {
topProcesses.push({
pid: parts[1],
user: parts[0],
cpu: parts[2],
mem: parts[3],
command: parts.slice(10).join(" ").substring(0, 50),
});
}
}
}
const procCount = await execCommand(client, "ps aux | wc -l");
const runningCount = await execCommand(
client,
"ps aux | grep -c ' R '",
);
totalProcesses = Number(procCount.stdout.trim()) - 1;
runningProcesses = Number(runningCount.stdout.trim());
} catch (e) {
statsLogger.debug("Failed to collect process stats", {
operation: "process_stats_failed",
error: e instanceof Error ? e.message : String(e),
});
}
return {
total: totalProcesses,
running: runningProcesses,
top: topProcesses,
};
}

View File

@@ -0,0 +1,37 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { statsLogger } from "../../utils/logger.js";
export async function collectSystemMetrics(client: Client): Promise<{
hostname: string | null;
kernel: string | null;
os: string | null;
}> {
let hostname: string | null = null;
let kernel: string | null = null;
let os: string | null = null;
try {
const hostnameOut = await execCommand(client, "hostname");
const kernelOut = await execCommand(client, "uname -r");
const osOut = await execCommand(
client,
"cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2",
);
hostname = hostnameOut.stdout.trim() || null;
kernel = kernelOut.stdout.trim() || null;
os = osOut.stdout.trim() || null;
} catch (e) {
statsLogger.debug("Failed to collect system info", {
operation: "system_info_failed",
error: e instanceof Error ? e.message : String(e),
});
}
return {
hostname,
kernel,
os,
};
}

View File

@@ -0,0 +1,35 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { statsLogger } from "../../utils/logger.js";
export async function collectUptimeMetrics(client: Client): Promise<{
seconds: number | null;
formatted: string | null;
}> {
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const uptimeOut = await execCommand(client, "cat /proc/uptime");
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
if (uptimeParts.length >= 1) {
uptimeSeconds = Number(uptimeParts[0]);
if (Number.isFinite(uptimeSeconds)) {
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
}
}
} catch (e) {
statsLogger.debug("Failed to collect uptime", {
operation: "uptime_failed",
error: e instanceof Error ? e.message : String(e),
});
}
return {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
};
}

View File

@@ -21,7 +21,11 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
if (persistentConfig.parsed) {
Object.assign(process.env, persistentConfig.parsed);
}
} catch {}
} catch (error) {
systemLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
let version = "unknown";

View File

@@ -233,7 +233,11 @@ IP.3 = 0.0.0.0
let envContent = "";
try {
envContent = await fs.readFile(this.ENV_FILE, "utf8");
} catch {}
} catch (error) {
systemLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
let updatedContent = envContent;
let hasChanges = false;

View File

@@ -12,6 +12,7 @@ interface EncryptedFileMetadata {
algorithm: string;
keySource?: string;
salt?: string;
dataSize?: number;
}
class DatabaseFileEncryption {
@@ -25,11 +26,12 @@ class DatabaseFileEncryption {
buffer: Buffer,
targetPath: string,
): Promise<string> {
const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`;
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
try {
const key = await this.systemCrypto.getDatabaseKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
this.ALGORITHM,
key,
@@ -45,14 +47,55 @@ class DatabaseFileEncryption {
fingerprint: "termix-v2-systemcrypto",
algorithm: this.ALGORITHM,
keySource: "SystemCrypto",
dataSize: encrypted.length,
};
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
fs.writeFileSync(targetPath, encrypted);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
const metadataJson = JSON.stringify(metadata, null, 2);
const metadataBuffer = Buffer.from(metadataJson, "utf8");
const metadataLengthBuffer = Buffer.alloc(4);
metadataLengthBuffer.writeUInt32BE(metadataBuffer.length, 0);
const finalBuffer = Buffer.concat([
metadataLengthBuffer,
metadataBuffer,
encrypted,
]);
fs.writeFileSync(tmpPath, finalBuffer);
fs.renameSync(tmpPath, targetPath);
try {
if (fs.existsSync(metadataPath)) {
fs.unlinkSync(metadataPath);
}
} catch (cleanupError) {
databaseLogger.warn("Failed to cleanup old metadata file", {
operation: "old_meta_cleanup_failed",
path: metadataPath,
error:
cleanupError instanceof Error
? cleanupError.message
: "Unknown error",
});
}
return targetPath;
} catch (error) {
try {
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
} catch (cleanupError) {
databaseLogger.warn("Failed to cleanup temporary files", {
operation: "temp_file_cleanup_failed",
tmpPath,
error:
cleanupError instanceof Error
? cleanupError.message
: "Unknown error",
});
}
databaseLogger.error("Failed to encrypt database buffer", error, {
operation: "database_buffer_encryption_failed",
targetPath,
@@ -74,6 +117,8 @@ class DatabaseFileEncryption {
const encryptedPath =
targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`;
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
const tmpPath = `${encryptedPath}.tmp-${Date.now()}-${process.pid}`;
const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`;
try {
const sourceData = fs.readFileSync(sourcePath);
@@ -93,6 +138,12 @@ class DatabaseFileEncryption {
]);
const tag = cipher.getAuthTag();
const keyFingerprint = crypto
.createHash("sha256")
.update(key)
.digest("hex")
.substring(0, 16);
const metadata: EncryptedFileMetadata = {
iv: iv.toString("hex"),
tag: tag.toString("hex"),
@@ -100,10 +151,14 @@ class DatabaseFileEncryption {
fingerprint: "termix-v2-systemcrypto",
algorithm: this.ALGORITHM,
keySource: "SystemCrypto",
dataSize: encrypted.length,
};
fs.writeFileSync(encryptedPath, encrypted);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
fs.writeFileSync(tmpPath, encrypted);
fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2));
fs.renameSync(tmpPath, encryptedPath);
fs.renameSync(tmpMetadataPath, metadataPath);
databaseLogger.info("Database file encrypted successfully", {
operation: "database_file_encryption",
@@ -111,11 +166,30 @@ class DatabaseFileEncryption {
encryptedPath,
fileSize: sourceData.length,
encryptedSize: encrypted.length,
keyFingerprint,
fingerprintPrefix: metadata.fingerprint,
});
return encryptedPath;
} catch (error) {
try {
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
if (fs.existsSync(tmpMetadataPath)) {
fs.unlinkSync(tmpMetadataPath);
}
} catch (cleanupError) {
databaseLogger.warn("Failed to cleanup temporary files", {
operation: "temp_file_cleanup_failed",
tmpPath,
error:
cleanupError instanceof Error
? cleanupError.message
: "Unknown error",
});
}
databaseLogger.error("Failed to encrypt database file", error, {
operation: "database_file_encryption_failed",
sourcePath,
@@ -134,16 +208,69 @@ class DatabaseFileEncryption {
);
}
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
if (!fs.existsSync(metadataPath)) {
throw new Error(`Metadata file does not exist: ${metadataPath}`);
let metadata: EncryptedFileMetadata;
let encryptedData: Buffer;
const fileBuffer = fs.readFileSync(encryptedPath);
try {
const metadataLength = fileBuffer.readUInt32BE(0);
const metadataEnd = 4 + metadataLength;
if (
metadataLength <= 0 ||
metadataEnd > fileBuffer.length ||
metadataEnd <= 4
) {
throw new Error("Invalid metadata length in single-file format");
}
const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8");
metadata = JSON.parse(metadataJson);
encryptedData = fileBuffer.slice(metadataEnd);
if (!metadata.iv || !metadata.tag || !metadata.version) {
throw new Error("Invalid metadata structure in single-file format");
}
} catch (singleFileError) {
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
if (!fs.existsSync(metadataPath)) {
throw new Error(
`Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`,
);
}
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
metadata = JSON.parse(metadataContent);
encryptedData = fileBuffer;
} catch (twoFileError) {
throw new Error(
`Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`,
);
}
}
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const encryptedData = fs.readFileSync(encryptedPath);
if (
metadata.dataSize !== undefined &&
encryptedData.length !== metadata.dataSize
) {
databaseLogger.error(
"Encrypted file size mismatch - possible corrupted write or mismatched metadata",
null,
{
operation: "database_file_size_mismatch",
encryptedPath,
actualSize: encryptedData.length,
expectedSize: metadata.dataSize,
},
);
throw new Error(
`Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` +
`This indicates corrupted files or interrupted write operation.`,
);
}
let key: Buffer;
if (metadata.version === "v2") {
@@ -181,13 +308,67 @@ class DatabaseFileEncryption {
return decryptedBuffer;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
const isAuthError =
errorMessage.includes("Unsupported state") ||
errorMessage.includes("authenticate data") ||
errorMessage.includes("auth");
if (isAuthError) {
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
let envFileExists = false;
let envFileReadable = false;
try {
envFileExists = fs.existsSync(envPath);
if (envFileExists) {
fs.accessSync(envPath, fs.constants.R_OK);
envFileReadable = true;
}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
databaseLogger.error(
"Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write",
error,
{
operation: "database_buffer_decryption_auth_failed",
encryptedPath,
dataDir,
envPath,
envFileExists,
envFileReadable,
hasEnvKey: !!process.env.DATABASE_KEY,
envKeyLength: process.env.DATABASE_KEY?.length || 0,
suggestion:
"Check if DATABASE_KEY in .env matches the key used for encryption",
},
);
throw new Error(
`Database decryption authentication failed. This usually means:\n` +
`1. DATABASE_KEY has changed or is missing from ${dataDir}/.env\n` +
`2. Encrypted file was corrupted during write (system crash/restart)\n` +
`3. Metadata file does not match encrypted data\n` +
`\nDebug info:\n` +
`- DATA_DIR: ${dataDir}\n` +
`- .env file exists: ${envFileExists}\n` +
`- .env file readable: ${envFileReadable}\n` +
`- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` +
`Original error: ${errorMessage}`,
);
}
databaseLogger.error("Failed to decrypt database to buffer", error, {
operation: "database_buffer_decryption_failed",
encryptedPath,
errorMessage,
});
throw new Error(
`Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
throw new Error(`Database buffer decryption failed: ${errorMessage}`);
}
}
@@ -215,6 +396,26 @@ class DatabaseFileEncryption {
const encryptedData = fs.readFileSync(encryptedPath);
if (
metadata.dataSize !== undefined &&
encryptedData.length !== metadata.dataSize
) {
databaseLogger.error(
"Encrypted file size mismatch - possible corrupted write or mismatched metadata",
null,
{
operation: "database_file_size_mismatch",
encryptedPath,
actualSize: encryptedData.length,
expectedSize: metadata.dataSize,
},
);
throw new Error(
`Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` +
`This indicates corrupted files or interrupted write operation.`,
);
}
let key: Buffer;
if (metadata.version === "v2") {
key = await this.systemCrypto.getDatabaseKey();
@@ -274,18 +475,43 @@ class DatabaseFileEncryption {
}
static isEncryptedDatabaseFile(filePath: string): boolean {
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
if (!fs.existsSync(filePath) || !fs.existsSync(metadataPath)) {
if (!fs.existsSync(filePath)) {
return false;
}
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
if (fs.existsSync(metadataPath)) {
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
return (
metadata.version === this.VERSION &&
metadata.algorithm === this.ALGORITHM
);
} catch {
return false;
}
}
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const fileBuffer = fs.readFileSync(filePath);
if (fileBuffer.length < 4) return false;
const metadataLength = fileBuffer.readUInt32BE(0);
const metadataEnd = 4 + metadataLength;
if (metadataLength <= 0 || metadataEnd > fileBuffer.length) {
return false;
}
const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataJson);
return (
metadata.version === this.VERSION &&
metadata.algorithm === this.ALGORITHM
metadata.algorithm === this.ALGORITHM &&
!!metadata.iv &&
!!metadata.tag
);
} catch {
return false;
@@ -322,6 +548,129 @@ class DatabaseFileEncryption {
}
}
static getDiagnosticInfo(encryptedPath: string): {
dataFile: {
exists: boolean;
size?: number;
mtime?: string;
readable?: boolean;
};
metadataFile: {
exists: boolean;
size?: number;
mtime?: string;
readable?: boolean;
content?: EncryptedFileMetadata;
};
environment: {
dataDir: string;
envPath: string;
envFileExists: boolean;
envFileReadable: boolean;
hasEnvKey: boolean;
envKeyLength: number;
};
validation: {
filesConsistent: boolean;
sizeMismatch?: boolean;
expectedSize?: number;
actualSize?: number;
};
} {
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
const result: ReturnType<typeof this.getDiagnosticInfo> = {
dataFile: { exists: false },
metadataFile: { exists: false },
environment: {
dataDir,
envPath,
envFileExists: false,
envFileReadable: false,
hasEnvKey: !!process.env.DATABASE_KEY,
envKeyLength: process.env.DATABASE_KEY?.length || 0,
},
validation: {
filesConsistent: false,
},
};
try {
result.dataFile.exists = fs.existsSync(encryptedPath);
if (result.dataFile.exists) {
try {
fs.accessSync(encryptedPath, fs.constants.R_OK);
result.dataFile.readable = true;
const stats = fs.statSync(encryptedPath);
result.dataFile.size = stats.size;
result.dataFile.mtime = stats.mtime.toISOString();
} catch {
result.dataFile.readable = false;
}
}
result.metadataFile.exists = fs.existsSync(metadataPath);
if (result.metadataFile.exists) {
try {
fs.accessSync(metadataPath, fs.constants.R_OK);
result.metadataFile.readable = true;
const stats = fs.statSync(metadataPath);
result.metadataFile.size = stats.size;
result.metadataFile.mtime = stats.mtime.toISOString();
const content = fs.readFileSync(metadataPath, "utf8");
result.metadataFile.content = JSON.parse(content);
} catch {
result.metadataFile.readable = false;
}
}
result.environment.envFileExists = fs.existsSync(envPath);
if (result.environment.envFileExists) {
try {
fs.accessSync(envPath, fs.constants.R_OK);
result.environment.envFileReadable = true;
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
}
if (
result.dataFile.exists &&
result.metadataFile.exists &&
result.metadataFile.content
) {
result.validation.filesConsistent = true;
if (result.metadataFile.content.dataSize !== undefined) {
result.validation.expectedSize = result.metadataFile.content.dataSize;
result.validation.actualSize = result.dataFile.size;
result.validation.sizeMismatch =
result.metadataFile.content.dataSize !== result.dataFile.size;
if (result.validation.sizeMismatch) {
result.validation.filesConsistent = false;
}
}
}
} catch (error) {
databaseLogger.error("Failed to generate diagnostic info", error, {
operation: "diagnostic_info_failed",
encryptedPath,
});
}
databaseLogger.info("Database encryption diagnostic info", {
operation: "diagnostic_info_generated",
...result,
});
return result;
}
static async createEncryptedBackup(
databasePath: string,
backupDir: string,

View File

@@ -82,7 +82,11 @@ export class LazyFieldEncryption {
legacyFieldName,
);
return decrypted;
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
}
const sensitiveFields = [
@@ -174,7 +178,11 @@ export class LazyFieldEncryption {
wasPlaintext: false,
wasLegacyEncryption: true,
};
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
}
return {
encrypted: fieldValue,

View File

@@ -0,0 +1,146 @@
interface LoginAttempt {
count: number;
firstAttempt: number;
lockedUntil?: number;
}
class LoginRateLimiter {
private ipAttempts = new Map<string, LoginAttempt>();
private usernameAttempts = new Map<string, LoginAttempt>();
private readonly MAX_ATTEMPTS = 5;
private readonly WINDOW_MS = 15 * 60 * 1000; // 15 minutes
private readonly LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
// Clean up old entries periodically
constructor() {
setInterval(() => this.cleanup(), 5 * 60 * 1000); // Clean every 5 minutes
}
private cleanup(): void {
const now = Date.now();
// Clean IP attempts
for (const [ip, attempt] of this.ipAttempts.entries()) {
if (attempt.lockedUntil && attempt.lockedUntil < now) {
this.ipAttempts.delete(ip);
} else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) {
this.ipAttempts.delete(ip);
}
}
// Clean username attempts
for (const [username, attempt] of this.usernameAttempts.entries()) {
if (attempt.lockedUntil && attempt.lockedUntil < now) {
this.usernameAttempts.delete(username);
} else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) {
this.usernameAttempts.delete(username);
}
}
}
recordFailedAttempt(ip: string, username?: string): void {
const now = Date.now();
// Record IP attempt
const ipAttempt = this.ipAttempts.get(ip);
if (!ipAttempt) {
this.ipAttempts.set(ip, {
count: 1,
firstAttempt: now,
});
} else if ((now - ipAttempt.firstAttempt) > this.WINDOW_MS) {
// Reset if outside window
this.ipAttempts.set(ip, {
count: 1,
firstAttempt: now,
});
} else {
ipAttempt.count++;
if (ipAttempt.count >= this.MAX_ATTEMPTS) {
ipAttempt.lockedUntil = now + this.LOCKOUT_MS;
}
}
// Record username attempt if provided
if (username) {
const userAttempt = this.usernameAttempts.get(username);
if (!userAttempt) {
this.usernameAttempts.set(username, {
count: 1,
firstAttempt: now,
});
} else if ((now - userAttempt.firstAttempt) > this.WINDOW_MS) {
// Reset if outside window
this.usernameAttempts.set(username, {
count: 1,
firstAttempt: now,
});
} else {
userAttempt.count++;
if (userAttempt.count >= this.MAX_ATTEMPTS) {
userAttempt.lockedUntil = now + this.LOCKOUT_MS;
}
}
}
}
resetAttempts(ip: string, username?: string): void {
this.ipAttempts.delete(ip);
if (username) {
this.usernameAttempts.delete(username);
}
}
isLocked(ip: string, username?: string): { locked: boolean; remainingTime?: number } {
const now = Date.now();
// Check IP lockout
const ipAttempt = this.ipAttempts.get(ip);
if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) {
return {
locked: true,
remainingTime: Math.ceil((ipAttempt.lockedUntil - now) / 1000),
};
}
// Check username lockout
if (username) {
const userAttempt = this.usernameAttempts.get(username);
if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) {
return {
locked: true,
remainingTime: Math.ceil((userAttempt.lockedUntil - now) / 1000),
};
}
}
return { locked: false };
}
getRemainingAttempts(ip: string, username?: string): number {
const now = Date.now();
let minRemaining = this.MAX_ATTEMPTS;
// Check IP attempts
const ipAttempt = this.ipAttempts.get(ip);
if (ipAttempt && (now - ipAttempt.firstAttempt) <= this.WINDOW_MS) {
const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count);
minRemaining = Math.min(minRemaining, ipRemaining);
}
// Check username attempts
if (username) {
const userAttempt = this.usernameAttempts.get(username);
if (userAttempt && (now - userAttempt.firstAttempt) <= this.WINDOW_MS) {
const userRemaining = Math.max(0, this.MAX_ATTEMPTS - userAttempt.count);
minRemaining = Math.min(minRemaining, userRemaining);
}
}
return minRemaining;
}
}
// Export singleton instance
export const loginRateLimiter = new LoginRateLimiter();

View File

@@ -1,4 +1,5 @@
import ssh2Pkg from "ssh2";
import { sshLogger } from "./logger.js";
const ssh2Utils = ssh2Pkg.utils;
function detectKeyTypeFromContent(keyContent: string): string {
@@ -84,7 +85,11 @@ function detectKeyTypeFromContent(keyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
if (content.length < 800) {
return "ssh-ed25519";
@@ -140,7 +145,11 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
if (content.length < 400) {
return "ssh-ed25519";
@@ -242,7 +251,11 @@ export function parseSSHKey(
useSSH2 = true;
}
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
}
if (!useSSH2) {
@@ -268,7 +281,11 @@ export function parseSSHKey(
success: true,
};
}
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
return {
privateKey: privateKeyData,

View File

@@ -52,16 +52,15 @@ class SystemCrypto {
);
}
} catch (fileError) {
databaseLogger.warn("Failed to read .env file for JWT secret", {
operation: "jwt_init_file_read_failed",
error:
fileError instanceof Error ? fileError.message : "Unknown error",
});
// OK: .env file not found or unreadable, will generate new JWT secret
databaseLogger.debug(
".env file not accessible, will generate new JWT secret",
{
operation: "jwt_env_not_found",
},
);
}
databaseLogger.warn("Generating new JWT secret", {
operation: "jwt_generating_new_secret",
});
await this.generateAndGuideUser();
} catch (error) {
databaseLogger.error("Failed to initialize JWT secret", error, {
@@ -80,29 +79,52 @@ class SystemCrypto {
async initializeDatabaseKey(): Promise<void> {
try {
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
const envKey = process.env.DATABASE_KEY;
if (envKey && envKey.length >= 64) {
this.databaseKey = Buffer.from(envKey, "hex");
const keyFingerprint = crypto
.createHash("sha256")
.update(this.databaseKey)
.digest("hex")
.substring(0, 16);
return;
}
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
try {
const envContent = await fs.readFile(envPath, "utf8");
const dbKeyMatch = envContent.match(/^DATABASE_KEY=(.+)$/m);
if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) {
this.databaseKey = Buffer.from(dbKeyMatch[1], "hex");
process.env.DATABASE_KEY = dbKeyMatch[1];
const keyFingerprint = crypto
.createHash("sha256")
.update(this.databaseKey)
.digest("hex")
.substring(0, 16);
return;
} else {
}
} catch {}
} catch (fileError) {
// OK: .env file not found or unreadable, will generate new database key
databaseLogger.debug(
".env file not accessible, will generate new database key",
{
operation: "db_key_env_not_found",
},
);
}
await this.generateAndGuideDatabaseKey();
} catch (error) {
databaseLogger.error("Failed to initialize database key", error, {
operation: "db_key_init_failed",
dataDir: process.env.DATA_DIR || "./db/data",
});
throw new Error("Database key initialization failed");
}
@@ -134,7 +156,11 @@ class SystemCrypto {
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
return;
}
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
await this.generateAndGuideInternalAuthToken();
} catch (error) {

View File

@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95",
{
variants: {
variant: {

View File

@@ -0,0 +1,184 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,141 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,

28
src/components/ui/kbd.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -40,7 +40,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] duration-200 focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -55,7 +55,13 @@ function TabsContent({
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
className={cn(
"flex-1 outline-none",
"data-[state=active]:animate-in data-[state=inactive]:animate-out",
"data-[state=inactive]:fade-out-0 data-[state=active]:fade-in-0",
"duration-150",
className
)}
{...props}
/>
);

View File

@@ -187,7 +187,7 @@
"commandsWillBeSent": "Befehle werden an {{count}} ausgewählte Terminals gesendet.",
"settings": "Einstellungen",
"enableRightClickCopyPaste": "Rechtsklick-Kopieren\/Einfügen aktivieren",
"shareIdeas": "Haben Sie Ideen, was als nächstes für SSH-Tools kommen sollte? Teilen Sie diese mit uns"
"shareIdeas": "Haben Sie Vorschläge welche weiteren SSH-Tools ergänzt werden sollen? Dann teilen Sie diese gerne mit uns auf"
},
"homepage": {
"loggedInTitle": "Eingeloggt!",
@@ -355,7 +355,7 @@
"tabNavigation": "Registerkarte Navigation"
},
"admin": {
"title": "Administratoreinstellungen",
"title": "Admin-Einstellungen",
"oidc": "OIDC",
"users": "Benutzer",
"userManagement": "Benutzerverwaltung",
@@ -390,11 +390,11 @@
"actions": "Aktionen",
"external": "Extern",
"local": "Lokal",
"adminManagement": "Verwaltung von Administratoren",
"adminManagement": "Admin Verwaltung",
"makeUserAdmin": "Benutzer zum Administrator machen",
"adding": "Hinzufügen...",
"currentAdmins": "Aktuelle Administratoren",
"adminBadge": "Administrator",
"adminBadge": "Admin",
"removeAdminButton": "Administrator entfernen",
"general": "Allgemein",
"userRegistration": "Benutzerregistrierung",
@@ -416,6 +416,13 @@
"userDeletedSuccessfully": "Benutzer {{username}} wurde erfolgreich gelöscht",
"failedToDeleteUser": "Benutzer konnte nicht gelöscht werden",
"overrideUserInfoUrl": "URL für Benutzerinformationen überschreiben (nicht erforderlich)",
"failedToFetchSessions": "Fehler beim Abrufen der Sitzungen",
"sessionRevokedSuccessfully": "Sitzung erfolgreich widerrufen",
"failedToRevokeSession": "Sitzung konnte nicht widerrufen werden",
"confirmRevokeSession": "Möchten Sie diese Sitzung wirklich beenden?",
"confirmRevokeAllSessions": "Möchten Sie wirklich alle Sitzungen dieses Benutzers beenden?",
"failedToRevokeSessions": "Sitzungen konnten nicht widerrufen werden",
"sessionsRevokedSuccessfully": "Sitzungen erfolgreich beendet",
"databaseSecurity": "Datenbanksicherheit",
"encryptionStatus": "Verschlüsselungsstatus",
"encryptionEnabled": "Verschlüsselung aktiviert",
@@ -620,7 +627,7 @@
"autoStartContainer": "Automatischer Start beim Container-Start",
"autoStartDesc": "Diesen Tunnel beim Start des Containers automatisch starten",
"addConnection": "Tunnelverbindung hinzufügen",
"sshpassRequired": "Sshpass erforderlich für die Passwort-Authentifizierung",
"sshpassRequired": "sshpass erforderlich für die Passwort-Authentifizierung",
"sshpassRequiredDesc": "Für die Passwortauthentifizierung in Tunneln muss sshpass auf dem System installiert sein.",
"otherInstallMethods": "Andere Installationsmethoden:",
"debianUbuntuEquivalent": "(Debian\/Ubuntu) oder das entsprechende Pendant für Ihr Betriebssystem.",
@@ -699,6 +706,69 @@
"statusMonitoring": "Status",
"metricsMonitoring": "Metriken",
"terminalCustomizationNotice": "Hinweis: Terminal-Anpassungen funktionieren nur in der Desktop-Website-Version. Mobile und Electron-Apps verwenden die Standard-Terminaleinstellungen des Systems.",
"terminalCustomization": "Terminal-Anpassung",
"appearance": "Aussehen",
"behavior": "Verhalten",
"advanced": "Erweitert",
"themePreview": "Themen-Vorschau",
"theme": "Thema",
"selectTheme": "Thema auswählen",
"chooseColorTheme": "Wählen Sie ein Farbthema für das Terminal",
"fontFamily": "Schriftfamilie",
"selectFont": "Schriftart auswählen",
"selectFontDesc": "Wählen Sie die im Terminal zu verwendende Schriftart",
"fontSize": "Schriftgröße",
"fontSizeValue": "Schriftgröße: {{value}}px",
"adjustFontSize": "Terminal-Schriftgröße anpassen",
"letterSpacing": "Zeichenabstand",
"letterSpacingValue": "Zeichenabstand: {{value}}px",
"adjustLetterSpacing": "Abstand zwischen Zeichen anpassen",
"lineHeight": "Zeilenhöhe",
"lineHeightValue": "Zeilenhöhe: {{value}}",
"adjustLineHeight": "Abstand zwischen Zeilen anpassen",
"cursorStyle": "Cursor-Stil",
"selectCursorStyle": "Cursor-Stil auswählen",
"cursorStyleBlock": "Block",
"cursorStyleUnderline": "Unterstrich",
"cursorStyleBar": "Balken",
"chooseCursorAppearance": "Cursor-Erscheinungsbild wählen",
"cursorBlink": "Cursor-Blinken",
"enableCursorBlink": "Cursor-Blinkanimation aktivieren",
"scrollbackBuffer": "Rückwärts-Puffer",
"scrollbackBufferValue": "Rückwärts-Puffer: {{value}} Zeilen",
"scrollbackBufferDesc": "Anzahl der Zeilen im Rückwärtsverlauf",
"bellStyle": "Signalton-Stil",
"selectBellStyle": "Signalton-Stil auswählen",
"bellStyleNone": "Keine",
"bellStyleSound": "Ton",
"bellStyleVisual": "Visuell",
"bellStyleBoth": "Beides",
"bellStyleDesc": "Behandlung des Terminal-Signaltons (BEL-Zeichen, \\x07). Programme lösen dies aus, wenn Aufgaben abgeschlossen werden, Fehler auftreten oder für Benachrichtigungen. \"Ton\" spielt einen akustischen Signalton ab, \"Visuell\" lässt den Bildschirm kurz aufblinken, \"Beides\" macht beides, \"Keine\" deaktiviert Signalton-Benachrichtigungen.",
"rightClickSelectsWord": "Rechtsklick wählt Wort",
"rightClickSelectsWordDesc": "Rechtsklick wählt das Wort unter dem Cursor aus",
"fastScrollModifier": "Schnellscroll-Modifikator",
"selectModifier": "Modifikator auswählen",
"modifierAlt": "Alt",
"modifierCtrl": "Strg",
"modifierShift": "Umschalt",
"fastScrollModifierDesc": "Modifikatortaste für schnelles Scrollen",
"fastScrollSensitivity": "Schnellscroll-Empfindlichkeit",
"fastScrollSensitivityValue": "Schnellscroll-Empfindlichkeit: {{value}}",
"fastScrollSensitivityDesc": "Scroll-Geschwindigkeitsmultiplikator bei gedrücktem Modifikator",
"minimumContrastRatio": "Minimales Kontrastverhältnis",
"minimumContrastRatioValue": "Minimales Kontrastverhältnis: {{value}}",
"minimumContrastRatioDesc": "Farben automatisch für bessere Lesbarkeit anpassen",
"sshAgentForwarding": "SSH-Agent-Weiterleitung",
"sshAgentForwardingDesc": "SSH-Authentifizierungsagent an Remote-Host weiterleiten",
"backspaceMode": "Rücktaste-Modus",
"selectBackspaceMode": "Rücktaste-Modus auswählen",
"backspaceModeNormal": "Normal (DEL)",
"backspaceModeControlH": "Control-H (^H)",
"backspaceModeDesc": "Rücktasten-Verhalten für Kompatibilität",
"startupSnippet": "Start-Snippet",
"selectSnippet": "Snippet auswählen",
"searchSnippets": "Snippets durchsuchen...",
"snippetNone": "Keine",
"noneAuthTitle": "Keyboard-Interactive-Authentifizierung",
"noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.",
"noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern.",
@@ -1072,7 +1142,7 @@
"used": "Gebraucht",
"percentage": "Prozentsatz",
"refreshStatusAndMetrics": "Aktualisierungsstatus und Metriken",
"refreshStatus": "Aktualisierungsstatus",
"refreshStatus": "Aktualisieren",
"fileManagerAlreadyOpen": "Der Dateimanager ist für diesen Host bereits geöffnet",
"openFileManager": "Dateimanager öffnen",
"cpuCores_one": "{{count}} CPU",
@@ -1081,9 +1151,10 @@
"loadAverageNA": "Durchschnitt: N\/A",
"cpuUsage": "CPU-Auslastung",
"memoryUsage": "Speicherauslastung",
"diskUsage": "Festplattennutzung",
"rootStorageSpace": "Root-Speicherplatz",
"of": "von",
"feedbackMessage": "Haben Sie Ideen für die nächsten Schritte im Bereich der Serververwaltung? Teilen Sie diese mit uns",
"feedbackMessage": "Haben Sie Ideen, wie es bei der Serververwaltung weitergehen könnte? Dann teilen Sie diese gerne mit uns auf",
"failedToFetchHostConfig": "Abrufen der Hostkonfiguration fehlgeschlagen",
"failedToFetchStatus": "Abrufen des Serverstatus fehlgeschlagen",
"failedToFetchMetrics": "Abrufen der Servermetriken fehlgeschlagen",
@@ -1092,9 +1163,40 @@
"refreshing": "Aktualisieren...",
"serverOffline": "Server offline",
"cannotFetchMetrics": "Metriken können nicht vom Offline-Server abgerufen werden",
"load": "Laden"
"load": "Last",
"available": "Verfügbar",
"editLayout": "Layout anpassen",
"cancelEdit": "Abbrechen",
"addWidget": "Widget hinzufügen",
"saveLayout": "Layout speichern",
"unsavedChanges": "Ungespeicherte Änderungen",
"layoutSaved": "Layout erfolgreich gespeichert",
"failedToSaveLayout": "Speichern des Layout fehlgeschlagen",
"systemInfo": "System Information",
"hostname": "Hostname",
"operatingSystem": "Betriebssystem",
"kernel": "Kernel",
"totalUptime": "Gesamte Betriebszeit",
"seconds": "Sekunden",
"networkInterfaces": "Netzwerkschnittstellen",
"noInterfacesFound": "Keine Netzwerkschnittstellen gefunden",
"totalProcesses": "Gesamtprozesse",
"running": "läuft",
"noProcessesFound": "Keine Prozesse gefunden",
"loginStats": "SSH-Anmeldestatistiken",
"totalLogins": "Gesamtanmeldungen",
"uniqueIPs": "Eindeutige IPs",
"recentSuccessfulLogins": "Letzte erfolgreiche Anmeldungen",
"recentFailedAttempts": "Letzte fehlgeschlagene Versuche",
"noRecentLoginData": "Keine aktuellen Anmeldedaten",
"from": "von"
},
"auth": {
"tagline": "SSH TERMINAL MANAGER",
"description": "Sichere, leistungsstarke und intuitive SSH-Verbindungsverwaltung",
"welcomeBack": "Willkommen zurück bei TERMIX",
"createAccount": "Erstellen Sie Ihr TERMIX-Konto",
"continueExternal": "Mit externem Anbieter fortfahren",
"loginTitle": "Melden Sie sich bei Termix an",
"registerTitle": "Benutzerkonto erstellen",
"loginButton": "Anmelden",
@@ -1372,7 +1474,7 @@
"disconnected": "Getrennt",
"maxRetriesExhausted": "Maximale Wiederholungsversuche ausgeschöpft",
"endpointHostNotFound": "Endpunkthost nicht gefunden",
"administrator": "Administrator",
"administrator": "Admin",
"user": "Benutzer",
"external": "Extern",
"local": "Lokal",
@@ -1474,5 +1576,28 @@
"cpu": "CPU",
"ram": "RAM",
"notAvailable": "Nicht verfügbar"
},
"commandPalette": {
"searchPlaceholder": "Nach Hosts oder Schnellaktionen suchen...",
"recentActivity": "Kürzliche Aktivität",
"navigation": "Navigation",
"addHost": "Host hinzufügen",
"addCredential": "Anmeldedaten hinzufügen",
"adminSettings": "Admin-Einstellungen",
"userProfile": "Benutzerprofil",
"updateLog": "Aktualisierungsprotokoll",
"hosts": "Hosts",
"openServerDetails": "Serverdetails öffnen",
"openFileManager": "Dateimanager öffnen",
"edit": "Bearbeiten",
"links": "Links",
"github": "GitHub",
"support": "Support",
"discord": "Discord",
"donate": "Spenden",
"press": "Drücken Sie",
"toToggle": "zum Umschalten",
"close": "Schließen",
"hostManager": "Host-Manager"
}
}

View File

@@ -754,6 +754,17 @@
"failedToRemoveFromFolder": "Failed to remove host from folder",
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
"failedToRenameFolder": "Failed to rename folder",
"editFolderAppearance": "Edit Folder Appearance",
"editFolderAppearanceDesc": "Customize the color and icon for folder",
"folderColor": "Folder Color",
"folderIcon": "Folder Icon",
"preview": "Preview",
"folderAppearanceUpdated": "Folder appearance updated successfully",
"failedToUpdateFolderAppearance": "Failed to update folder appearance",
"deleteAllHostsInFolder": "Delete All Hosts in Folder",
"confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.",
"allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully",
"failedToDeleteHostsInFolder": "Failed to delete hosts in folder",
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
"failedToMoveToFolder": "Failed to move host to folder",
"statistics": "Statistics",
@@ -778,6 +789,69 @@
"statusMonitoring": "Status",
"metricsMonitoring": "Metrics",
"terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.",
"terminalCustomization": "Terminal Customization",
"appearance": "Appearance",
"behavior": "Behavior",
"advanced": "Advanced",
"themePreview": "Theme Preview",
"theme": "Theme",
"selectTheme": "Select theme",
"chooseColorTheme": "Choose a color theme for the terminal",
"fontFamily": "Font Family",
"selectFont": "Select font",
"selectFontDesc": "Select the font to use in the terminal",
"fontSize": "Font Size",
"fontSizeValue": "Font Size: {{value}}px",
"adjustFontSize": "Adjust the terminal font size",
"letterSpacing": "Letter Spacing",
"letterSpacingValue": "Letter Spacing: {{value}}px",
"adjustLetterSpacing": "Adjust spacing between characters",
"lineHeight": "Line Height",
"lineHeightValue": "Line Height: {{value}}",
"adjustLineHeight": "Adjust spacing between lines",
"cursorStyle": "Cursor Style",
"selectCursorStyle": "Select cursor style",
"cursorStyleBlock": "Block",
"cursorStyleUnderline": "Underline",
"cursorStyleBar": "Bar",
"chooseCursorAppearance": "Choose the cursor appearance",
"cursorBlink": "Cursor Blink",
"enableCursorBlink": "Enable cursor blinking animation",
"scrollbackBuffer": "Scrollback Buffer",
"scrollbackBufferValue": "Scrollback Buffer: {{value}} lines",
"scrollbackBufferDesc": "Number of lines to keep in scrollback history",
"bellStyle": "Bell Style",
"selectBellStyle": "Select bell style",
"bellStyleNone": "None",
"bellStyleSound": "Sound",
"bellStyleVisual": "Visual",
"bellStyleBoth": "Both",
"bellStyleDesc": "How to handle terminal bell (BEL character, \\x07). Programs trigger this when completing tasks, encountering errors, or for notifications. \"Sound\" plays an audio beep, \"Visual\" flashes the screen briefly, \"Both\" does both, \"None\" disables bell alerts.",
"rightClickSelectsWord": "Right Click Selects Word",
"rightClickSelectsWordDesc": "Right-clicking selects the word under cursor",
"fastScrollModifier": "Fast Scroll Modifier",
"selectModifier": "Select modifier",
"modifierAlt": "Alt",
"modifierCtrl": "Ctrl",
"modifierShift": "Shift",
"fastScrollModifierDesc": "Modifier key for fast scrolling",
"fastScrollSensitivity": "Fast Scroll Sensitivity",
"fastScrollSensitivityValue": "Fast Scroll Sensitivity: {{value}}",
"fastScrollSensitivityDesc": "Scroll speed multiplier when modifier is held",
"minimumContrastRatio": "Minimum Contrast Ratio",
"minimumContrastRatioValue": "Minimum Contrast Ratio: {{value}}",
"minimumContrastRatioDesc": "Automatically adjust colors for better readability",
"sshAgentForwarding": "SSH Agent Forwarding",
"sshAgentForwardingDesc": "Forward SSH authentication agent to remote host",
"backspaceMode": "Backspace Mode",
"selectBackspaceMode": "Select backspace mode",
"backspaceModeNormal": "Normal (DEL)",
"backspaceModeControlH": "Control-H (^H)",
"backspaceModeDesc": "Backspace key behavior for compatibility",
"startupSnippet": "Startup Snippet",
"selectSnippet": "Select snippet",
"searchSnippets": "Search snippets...",
"snippetNone": "None",
"noneAuthTitle": "Keyboard-Interactive Authentication",
"noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.",
"noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.",
@@ -829,6 +903,22 @@
"connectToSsh": "Connect to SSH to use file operations",
"uploadFile": "Upload File",
"downloadFile": "Download",
"extractArchive": "Extract Archive",
"extractingArchive": "Extracting {{name}}...",
"archiveExtractedSuccessfully": "{{name}} extracted successfully",
"extractFailed": "Extract failed",
"compressFile": "Compress File",
"compressFiles": "Compress Files",
"compressFilesDesc": "Compress {{count}} items into an archive",
"archiveName": "Archive Name",
"enterArchiveName": "Enter archive name...",
"compressionFormat": "Compression Format",
"selectedFiles": "Selected files",
"andMoreFiles": "and {{count}} more...",
"compress": "Compress",
"compressingFiles": "Compressing {{count}} items into {{name}}...",
"filesCompressedSuccessfully": "{{name}} created successfully",
"compressFailed": "Compression failed",
"edit": "Edit",
"preview": "Preview",
"previous": "Previous",
@@ -1106,7 +1196,19 @@
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Failed to load file: {{error}}",
"connectedSuccessfully": "Connected successfully",
"totpVerificationFailed": "TOTP verification failed"
"totpVerificationFailed": "TOTP verification failed",
"changePermissions": "Change Permissions",
"changePermissionsDesc": "Modify file permissions for",
"currentPermissions": "Current Permissions",
"newPermissions": "New Permissions",
"owner": "Owner",
"group": "Group",
"others": "Others",
"read": "Read",
"write": "Write",
"execute": "Execute",
"permissionsChangedSuccessfully": "Permissions changed successfully",
"failedToChangePermissions": "Failed to change permissions"
},
"tunnels": {
"title": "SSH Tunnels",
@@ -1210,7 +1312,6 @@
"totpRequired": "TOTP Authentication Required",
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
"load": "Load",
"free": "Free",
"available": "Available",
"editLayout": "Edit Layout",
"cancelEdit": "Cancel",
@@ -1229,9 +1330,21 @@
"noInterfacesFound": "No network interfaces found",
"totalProcesses": "Total Processes",
"running": "Running",
"noProcessesFound": "No processes found"
"noProcessesFound": "No processes found",
"loginStats": "SSH Login Statistics",
"totalLogins": "Total Logins",
"uniqueIPs": "Unique IPs",
"recentSuccessfulLogins": "Recent Successful Logins",
"recentFailedAttempts": "Recent Failed Attempts",
"noRecentLoginData": "No recent login data",
"from": "from"
},
"auth": {
"tagline": "SSH TERMINAL MANAGER",
"description": "Secure, powerful, and intuitive SSH connection management",
"welcomeBack": "Welcome back to TERMIX",
"createAccount": "Create your TERMIX account",
"continueExternal": "Continue with external provider",
"loginTitle": "Login to Termix",
"registerTitle": "Create Account",
"loginButton": "Login",
@@ -1405,6 +1518,8 @@
"local": "Local",
"external": "External (OIDC)",
"selectPreferredLanguage": "Select your preferred language for the interface",
"fileColorCoding": "File Color Coding",
"fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)",
"currentPassword": "Current Password",
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
@@ -1620,5 +1735,28 @@
"cpu": "CPU",
"ram": "RAM",
"notAvailable": "N/A"
},
"commandPalette": {
"searchPlaceholder": "Search for hosts or quick actions...",
"recentActivity": "Recent Activity",
"navigation": "Navigation",
"addHost": "Add Host",
"addCredential": "Add Credential",
"adminSettings": "Admin Settings",
"userProfile": "User Profile",
"updateLog": "Update Log",
"hosts": "Hosts",
"openServerDetails": "Open Server Details",
"openFileManager": "Open File Manager",
"edit": "Edit",
"links": "Links",
"github": "GitHub",
"support": "Support",
"discord": "Discord",
"donate": "Donate",
"press": "Press",
"toToggle": "to toggle",
"close": "Close",
"hostManager": "Host Manager"
}
}

View File

@@ -1143,6 +1143,11 @@
"available": "Disponível"
},
"auth": {
"tagline": "GERENCIADOR DE TERMINAL SSH",
"description": "Gerenciamento de conexão SSH seguro, poderoso e intuitivo",
"welcomeBack": "Bem-vindo de volta ao TERMIX",
"createAccount": "Crie sua conta TERMIX",
"continueExternal": "Continuar com provedor externo",
"loginTitle": "Entrar no Termix",
"registerTitle": "Criar Conta",
"loginButton": "Entrar",

View File

@@ -766,6 +766,69 @@
"statusMonitoring": "Статус",
"metricsMonitoring": "Метрики",
"terminalCustomizationNotice": "Примечание: Настройки терминала работают только на рабочем столе (веб-сайт и Electron-приложение). Мобильные приложения и мобильный веб-сайт используют системные настройки терминала по умолчанию.",
"terminalCustomization": "Настройка терминала",
"appearance": "Внешний вид",
"behavior": "Поведение",
"advanced": "Расширенные",
"themePreview": "Предпросмотр темы",
"theme": "Тема",
"selectTheme": "Выбрать тему",
"chooseColorTheme": "Выберите цветовую тему для терминала",
"fontFamily": "Семейство шрифтов",
"selectFont": "Выбрать шрифт",
"selectFontDesc": "Выберите шрифт для использования в терминале",
"fontSize": "Размер шрифта",
"fontSizeValue": "Размер шрифта: {{value}}px",
"adjustFontSize": "Настроить размер шрифта терминала",
"letterSpacing": "Межбуквенный интервал",
"letterSpacingValue": "Межбуквенный интервал: {{value}}px",
"adjustLetterSpacing": "Настроить расстояние между символами",
"lineHeight": "Высота строки",
"lineHeightValue": "Высота строки: {{value}}",
"adjustLineHeight": "Настроить расстояние между строками",
"cursorStyle": "Стиль курсора",
"selectCursorStyle": "Выбрать стиль курсора",
"cursorStyleBlock": "Блок",
"cursorStyleUnderline": "Подчеркивание",
"cursorStyleBar": "Полоса",
"chooseCursorAppearance": "Выбрать внешний вид курсора",
"cursorBlink": "Мигание курсора",
"enableCursorBlink": "Включить анимацию мигания курсора",
"scrollbackBuffer": "Буфер прокрутки",
"scrollbackBufferValue": "Буфер прокрутки: {{value}} строк",
"scrollbackBufferDesc": "Количество строк для хранения в истории прокрутки",
"bellStyle": "Стиль звонка",
"selectBellStyle": "Выбрать стиль звонка",
"bellStyleNone": "Нет",
"bellStyleSound": "Звук",
"bellStyleVisual": "Визуальный",
"bellStyleBoth": "Оба",
"bellStyleDesc": "Как обрабатывать звонок терминала (символ BEL, \\x07). Программы вызывают его при завершении задач, возникновении ошибок или для уведомлений. \"Звук\" воспроизводит звуковой сигнал, \"Визуальный\" кратковременно мигает экран, \"Оба\" делает и то, и другое, \"Нет\" отключает звуковые оповещения.",
"rightClickSelectsWord": "Правый клик выбирает слово",
"rightClickSelectsWordDesc": "Правый клик выбирает слово под курсором",
"fastScrollModifier": "Модификатор быстрой прокрутки",
"selectModifier": "Выбрать модификатор",
"modifierAlt": "Alt",
"modifierCtrl": "Ctrl",
"modifierShift": "Shift",
"fastScrollModifierDesc": "Клавиша-модификатор для быстрой прокрутки",
"fastScrollSensitivity": "Чувствительность быстрой прокрутки",
"fastScrollSensitivityValue": "Чувствительность быстрой прокрутки: {{value}}",
"fastScrollSensitivityDesc": "Множитель скорости прокрутки при удержании модификатора",
"minimumContrastRatio": "Минимальная контрастность",
"minimumContrastRatioValue": "Минимальная контрастность: {{value}}",
"minimumContrastRatioDesc": "Автоматически настраивать цвета для лучшей читаемости",
"sshAgentForwarding": "Переадресация SSH-агента",
"sshAgentForwardingDesc": "Переадресовать агент SSH-аутентификации на удаленный хост",
"backspaceMode": "Режим Backspace",
"selectBackspaceMode": "Выбрать режим Backspace",
"backspaceModeNormal": "Обычный (DEL)",
"backspaceModeControlH": "Control-H (^H)",
"backspaceModeDesc": "Поведение клавиши Backspace для совместимости",
"startupSnippet": "Сниппет запуска",
"selectSnippet": "Выбрать сниппет",
"searchSnippets": "Поиск сниппетов...",
"snippetNone": "Нет",
"noneAuthTitle": "Интерактивная аутентификация по клавиатуре",
"noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию по клавиатуре при подключении к SSH-серверу.",
"noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля."
@@ -1215,9 +1278,21 @@
"noInterfacesFound": "Сетевые интерфейсы не найдены",
"totalProcesses": "Всего процессов",
"running": "Запущено",
"noProcessesFound": "Процессы не найдены"
"noProcessesFound": "Процессы не найдены",
"loginStats": "Статистика входов SSH",
"totalLogins": "Всего входов",
"uniqueIPs": "Уникальные IP",
"recentSuccessfulLogins": "Последние успешные входы",
"recentFailedAttempts": "Последние неудачные попытки",
"noRecentLoginData": "Нет данных о недавних входах",
"from": "с"
},
"auth": {
"tagline": "SSH ТЕРМИНАЛ МЕНЕДЖЕР",
"description": "Безопасное, мощное и интуитивное управление SSH-соединениями",
"welcomeBack": "Добро пожаловать обратно в TERMIX",
"createAccount": "Создайте вашу учетную запись TERMIX",
"continueExternal": "Продолжить с внешним провайдером",
"loginTitle": "Вход в Termix",
"registerTitle": "Создать учетную запись",
"loginButton": "Войти",
@@ -1587,5 +1662,28 @@
"cpu": "CPU",
"ram": "RAM",
"notAvailable": "N/A"
},
"commandPalette": {
"searchPlaceholder": "Поиск хостов или быстрых действий...",
"recentActivity": "Недавняя активность",
"navigation": "Навигация",
"addHost": "Добавить хост",
"addCredential": "Добавить учетные данные",
"adminSettings": "Настройки администратора",
"userProfile": "Профиль пользователя",
"updateLog": "Журнал обновлений",
"hosts": "Хосты",
"openServerDetails": "Открыть детали сервера",
"openFileManager": "Открыть файловый менеджер",
"edit": "Редактировать",
"links": "Ссылки",
"github": "GitHub",
"support": "Поддержка",
"discord": "Discord",
"donate": "Пожертвовать",
"press": "Нажмите",
"toToggle": "для переключения",
"close": "Закрыть",
"hostManager": "Менеджер хостов"
}
}

View File

@@ -766,6 +766,17 @@
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
"failedToRenameFolder": "重命名文件夹失败",
"editFolderAppearance": "编辑文件夹外观",
"editFolderAppearanceDesc": "自定义文件夹的颜色和图标",
"folderColor": "文件夹颜色",
"folderIcon": "文件夹图标",
"preview": "预览",
"folderAppearanceUpdated": "文件夹外观更新成功",
"failedToUpdateFolderAppearance": "更新文件夹外观失败",
"deleteAllHostsInFolder": "删除文件夹内所有主机",
"confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。",
"allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机",
"failedToDeleteHostsInFolder": "删除文件夹中的主机失败",
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
"failedToMoveToFolder": "移动主机到文件夹失败",
"statistics": "统计",
@@ -790,6 +801,69 @@
"statusMonitoring": "状态",
"metricsMonitoring": "指标",
"terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。",
"terminalCustomization": "终端自定义",
"appearance": "外观",
"behavior": "行为",
"advanced": "高级",
"themePreview": "主题预览",
"theme": "主题",
"selectTheme": "选择主题",
"chooseColorTheme": "选择终端的颜色主题",
"fontFamily": "字体系列",
"selectFont": "选择字体",
"selectFontDesc": "选择终端使用的字体",
"fontSize": "字体大小",
"fontSizeValue": "字体大小:{{value}}px",
"adjustFontSize": "调整终端字体大小",
"letterSpacing": "字母间距",
"letterSpacingValue": "字母间距:{{value}}px",
"adjustLetterSpacing": "调整字符之间的间距",
"lineHeight": "行高",
"lineHeightValue": "行高:{{value}}",
"adjustLineHeight": "调整行之间的间距",
"cursorStyle": "光标样式",
"selectCursorStyle": "选择光标样式",
"cursorStyleBlock": "块状",
"cursorStyleUnderline": "下划线",
"cursorStyleBar": "竖线",
"chooseCursorAppearance": "选择光标外观",
"cursorBlink": "光标闪烁",
"enableCursorBlink": "启用光标闪烁动画",
"scrollbackBuffer": "回滚缓冲区",
"scrollbackBufferValue": "回滚缓冲区:{{value}} 行",
"scrollbackBufferDesc": "保留在回滚历史记录中的行数",
"bellStyle": "铃声样式",
"selectBellStyle": "选择铃声样式",
"bellStyleNone": "无",
"bellStyleSound": "声音",
"bellStyleVisual": "视觉",
"bellStyleBoth": "两者",
"bellStyleDesc": "如何处理终端铃声BEL字符\\x07。程序在完成任务、遇到错误或通知时会触发此功能。\"声音\"播放音频提示音,\"视觉\"短暂闪烁屏幕,\"两者\"同时执行,\"无\"禁用铃声提醒。",
"rightClickSelectsWord": "右键选择单词",
"rightClickSelectsWordDesc": "右键单击选择光标下的单词",
"fastScrollModifier": "快速滚动修饰键",
"selectModifier": "选择修饰键",
"modifierAlt": "Alt",
"modifierCtrl": "Ctrl",
"modifierShift": "Shift",
"fastScrollModifierDesc": "快速滚动的修饰键",
"fastScrollSensitivity": "快速滚动灵敏度",
"fastScrollSensitivityValue": "快速滚动灵敏度:{{value}}",
"fastScrollSensitivityDesc": "按住修饰键时的滚动速度倍数",
"minimumContrastRatio": "最小对比度",
"minimumContrastRatioValue": "最小对比度:{{value}}",
"minimumContrastRatioDesc": "自动调整颜色以获得更好的可读性",
"sshAgentForwarding": "SSH 代理转发",
"sshAgentForwardingDesc": "将 SSH 身份验证代理转发到远程主机",
"backspaceMode": "退格模式",
"selectBackspaceMode": "选择退格模式",
"backspaceModeNormal": "正常 (DEL)",
"backspaceModeControlH": "Control-H (^H)",
"backspaceModeDesc": "退格键行为兼容性",
"startupSnippet": "启动代码片段",
"selectSnippet": "选择代码片段",
"searchSnippets": "搜索代码片段...",
"snippetNone": "无",
"noneAuthTitle": "键盘交互式认证",
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。",
@@ -841,6 +915,22 @@
"connectToSsh": "连接 SSH 以使用文件操作",
"uploadFile": "上传文件",
"downloadFile": "下载",
"extractArchive": "解压文件",
"extractingArchive": "正在解压 {{name}}...",
"archiveExtractedSuccessfully": "{{name}} 解压成功",
"extractFailed": "解压失败",
"compressFile": "压缩文件",
"compressFiles": "压缩文件",
"compressFilesDesc": "将 {{count}} 个项目压缩为归档文件",
"archiveName": "归档文件名",
"enterArchiveName": "输入归档文件名...",
"compressionFormat": "压缩格式",
"selectedFiles": "已选文件",
"andMoreFiles": "以及其他 {{count}} 个...",
"compress": "压缩",
"compressingFiles": "正在将 {{count}} 个项目压缩到 {{name}}...",
"filesCompressedSuccessfully": "{{name}} 创建成功",
"compressFailed": "压缩失败",
"edit": "编辑",
"preview": "预览",
"previous": "上一页",
@@ -1088,7 +1178,19 @@
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
"loadFileFailed": "加载文件失败:{{error}}",
"connectedSuccessfully": "连接成功",
"totpVerificationFailed": "TOTP 验证失败"
"totpVerificationFailed": "TOTP 验证失败",
"changePermissions": "修改权限",
"changePermissionsDesc": "修改文件权限",
"currentPermissions": "当前权限",
"newPermissions": "新权限",
"owner": "所有者",
"group": "组",
"others": "其他",
"read": "读取",
"write": "写入",
"execute": "执行",
"permissionsChangedSuccessfully": "权限修改成功",
"failedToChangePermissions": "权限修改失败"
},
"tunnels": {
"title": "SSH 隧道",
@@ -1199,9 +1301,21 @@
"noInterfacesFound": "未找到网络接口",
"totalProcesses": "总进程数",
"running": "运行中",
"noProcessesFound": "未找到进程"
"noProcessesFound": "未找到进程",
"loginStats": "SSH 登录统计",
"totalLogins": "总登录次数",
"uniqueIPs": "唯一 IP 数",
"recentSuccessfulLogins": "最近成功登录",
"recentFailedAttempts": "最近失败尝试",
"noRecentLoginData": "无最近登录数据",
"from": "来自"
},
"auth": {
"tagline": "SSH 终端管理器",
"description": "安全、强大、直观的 SSH 连接管理",
"welcomeBack": "欢迎回到 TERMIX",
"createAccount": "创建您的 TERMIX 账户",
"continueExternal": "使用外部提供商继续",
"loginTitle": "登录 Termix",
"registerTitle": "创建账户",
"loginButton": "登录",
@@ -1367,6 +1481,8 @@
"local": "本地",
"external": "外部 (OIDC)",
"selectPreferredLanguage": "选择您的界面首选语言",
"fileColorCoding": "文件颜色编码",
"fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)",
"currentPassword": "当前密码",
"passwordChangedSuccess": "密码修改成功!请重新登录。",
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
@@ -1512,5 +1628,28 @@
"cpu": "CPU",
"ram": "内存",
"notAvailable": "不可用"
},
"commandPalette": {
"searchPlaceholder": "搜索主机或快速操作...",
"recentActivity": "最近活动",
"navigation": "导航",
"addHost": "添加主机",
"addCredential": "添加凭据",
"adminSettings": "管理员设置",
"userProfile": "用户资料",
"updateLog": "更新日志",
"hosts": "主机",
"openServerDetails": "打开服务器详情",
"openFileManager": "打开文件管理器",
"edit": "编辑",
"links": "链接",
"github": "GitHub",
"support": "支持",
"discord": "Discord",
"donate": "捐赠",
"press": "按下",
"toToggle": "来切换",
"close": "关闭",
"hostManager": "主机管理器"
}
}

View File

@@ -62,6 +62,16 @@ export interface SSHHostData {
terminalConfig?: TerminalConfig;
}
export interface SSHFolder {
id: number;
userId: string;
name: string;
color?: string;
icon?: string;
createdAt: string;
updatedAt: string;
}
// ============================================================================
// CREDENTIAL TYPES
// ============================================================================

View File

@@ -5,7 +5,8 @@ export type WidgetType =
| "network"
| "uptime"
| "processes"
| "system";
| "system"
| "login_stats";
export interface StatsConfig {
enabledWidgets: WidgetType[];
@@ -16,7 +17,15 @@ export interface StatsConfig {
}
export const DEFAULT_STATS_CONFIG: StatsConfig = {
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"],
enabledWidgets: [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
],
statusCheckEnabled: true,
statusCheckInterval: 30,
metricsEnabled: true,

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
@@ -11,6 +11,7 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx";
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
import { getUserInfo } from "@/ui/main-axios.ts";
function AppContent() {
@@ -22,7 +23,32 @@ function AppContent() {
const saved = localStorage.getItem("topNavbarOpen");
return saved !== null ? JSON.parse(saved) : true;
});
const [isTransitioning, setIsTransitioning] = useState(false);
const [transitionPhase, setTransitionPhase] = useState<'idle' | 'fadeOut' | 'fadeIn'>('idle');
const { currentTab, tabs } = useTabs();
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const lastShiftPressTime = useRef(0);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code === "ShiftLeft") {
const now = Date.now();
if (now - lastShiftPressTime.current < 300) {
setIsCommandPaletteOpen((isOpen) => !isOpen);
}
lastShiftPressTime.current = now;
}
if (event.key === "Escape") {
setIsCommandPaletteOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
useEffect(() => {
const checkAuth = () => {
@@ -74,13 +100,44 @@ function AppContent() {
username: string | null;
userId: string | null;
}) => {
setIsAuthenticated(true);
setIsAdmin(authData.isAdmin);
setUsername(authData.username);
setIsTransitioning(true);
setTransitionPhase('fadeOut');
setTimeout(() => {
setIsAuthenticated(true);
setIsAdmin(authData.isAdmin);
setUsername(authData.username);
setTransitionPhase('fadeIn');
setTimeout(() => {
setIsTransitioning(false);
setTransitionPhase('idle');
}, 800);
}, 1200);
},
[],
);
const handleLogout = useCallback(async () => {
setIsTransitioning(true);
setTransitionPhase('fadeOut');
setTimeout(async () => {
try {
const { logoutUser, isElectron } = await import("@/ui/main-axios.ts");
await logoutUser();
if (isElectron()) {
localStorage.removeItem("jwt");
}
} catch (error) {
console.error("Logout failed:", error);
}
window.location.reload();
}, 1200);
}, []);
const currentTabData = tabs.find((tab) => tab.id === currentTab);
const showTerminalView =
currentTabData?.type === "terminal" ||
@@ -93,6 +150,10 @@ function AppContent() {
return (
<div>
<CommandPalette
isOpen={isCommandPaletteOpen}
setIsOpen={setIsCommandPaletteOpen}
/>
{!isAuthenticated && !authLoading && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Dashboard
@@ -107,11 +168,12 @@ function AppContent() {
{isAuthenticated && (
<LeftSidebar
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin}
username={username}
>
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin}
username={username}
onLogout={handleLogout}
>
<div
className="h-screen w-full visible pointer-events-auto static overflow-hidden"
style={{ display: showTerminalView ? "block" : "none" }}
@@ -157,9 +219,152 @@ function AppContent() {
<TopNavbar
isTopbarOpen={isTopbarOpen}
setIsTopbarOpen={setIsTopbarOpen}
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
/>
</LeftSidebar>
)}
{isTransitioning && (
<div
className={`fixed inset-0 bg-background z-[20000] transition-opacity duration-700 ${
transitionPhase === 'fadeOut' ? 'opacity-100' : 'opacity-0'
}`}
>
{transitionPhase === 'fadeOut' && (
<>
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
<div className="absolute w-0 h-0 bg-primary/10 rounded-full"
style={{
animation: 'ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards',
animationDelay: '0ms'
}}
/>
<div className="absolute w-0 h-0 bg-primary/7 rounded-full"
style={{
animation: 'ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards',
animationDelay: '200ms'
}}
/>
<div className="absolute w-0 h-0 bg-primary/5 rounded-full"
style={{
animation: 'ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards',
animationDelay: '400ms'
}}
/>
<div className="absolute w-0 h-0 bg-primary/3 rounded-full"
style={{
animation: 'ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards',
animationDelay: '600ms'
}}
/>
<div className="relative z-10 text-center"
style={{
animation: 'logoFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards'
}}>
<div className="text-7xl font-bold tracking-wider"
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
animation: 'logoGlow 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards'
}}>
TERMIX
</div>
<div className="text-sm text-muted-foreground mt-3 tracking-widest"
style={{
animation: 'subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards'
}}>
SSH TERMINAL MANAGER
</div>
</div>
</div>
<style>{`
@keyframes ripple {
0% {
width: 0;
height: 0;
opacity: 1;
}
30% {
opacity: 0.6;
}
70% {
opacity: 0.3;
}
100% {
width: 200vmax;
height: 200vmax;
opacity: 0;
}
}
@keyframes logoFade {
0% {
opacity: 0;
transform: scale(0.85);
filter: blur(8px);
}
25% {
opacity: 1;
transform: scale(1);
filter: blur(0px);
}
75% {
opacity: 1;
transform: scale(1);
filter: blur(0px);
}
100% {
opacity: 0;
transform: scale(1.05);
filter: blur(4px);
}
}
@keyframes logoGlow {
0% {
color: hsl(var(--primary));
text-shadow: none;
}
25% {
color: hsl(var(--primary));
text-shadow:
0 0 20px hsla(var(--primary), 0.3),
0 0 40px hsla(var(--primary), 0.2),
0 0 60px hsla(var(--primary), 0.1);
}
75% {
color: hsl(var(--primary));
text-shadow:
0 0 20px hsla(var(--primary), 0.3),
0 0 40px hsla(var(--primary), 0.2),
0 0 60px hsla(var(--primary), 0.1);
}
100% {
color: hsl(var(--primary));
text-shadow: none;
}
}
@keyframes subtitleFade {
0%, 30% {
opacity: 0;
transform: translateY(10px);
}
50% {
opacity: 1;
transform: translateY(0);
}
75% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-5px);
}
}
`}</style>
</>
)}
</div>
)}
<Toaster
position="bottom-right"
richColors={false}

View File

@@ -0,0 +1,404 @@
import {
Command,
CommandInput,
CommandItem,
CommandList,
CommandGroup,
CommandSeparator,
} from "@/components/ui/command.tsx";
import React, { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
Key,
Server,
Settings,
User,
Github,
Terminal,
FolderOpen,
Pencil,
EllipsisVertical,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { BiMoney, BiSupport } from "react-icons/bi";
import { BsDiscord } from "react-icons/bs";
import { GrUpdate } from "react-icons/gr";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
import type { RecentActivityItem } from "@/ui/main-axios.ts";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button.tsx";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: unknown[];
createdAt: string;
updatedAt: string;
}
export function CommandPalette({
isOpen,
setIsOpen,
}: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
[],
);
const [hosts, setHosts] = useState<SSHHost[]>([]);
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
getRecentActivity(50).then((activity) => {
setRecentActivity(activity.slice(0, 5));
});
getSSHHosts().then((allHosts) => {
setHosts(allHosts);
});
}
}, [isOpen]);
const handleAddHost = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_host" });
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
type: "ssh_manager",
title: t("commandPalette.hostManager"),
initialTab: "add_host",
});
setCurrentTab(id);
}
setIsOpen(false);
};
const handleAddCredential = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
type: "ssh_manager",
title: t("commandPalette.hostManager"),
initialTab: "add_credential",
});
setCurrentTab(id);
}
setIsOpen(false);
};
const handleOpenAdminSettings = () => {
const adminTab = tabList.find((t) => t.type === "admin");
if (adminTab) {
setCurrentTab(adminTab.id);
} else {
const id = addTab({ type: "admin", title: t("commandPalette.adminSettings") });
setCurrentTab(id);
}
setIsOpen(false);
};
const handleOpenUserProfile = () => {
const userProfileTab = tabList.find((t) => t.type === "user_profile");
if (userProfileTab) {
setCurrentTab(userProfileTab.id);
} else {
const id = addTab({ type: "user_profile", title: t("commandPalette.userProfile") });
setCurrentTab(id);
}
setIsOpen(false);
};
const handleOpenUpdateLog = () => {
window.open("https://github.com/Termix-SSH/Termix/releases", "_blank");
setIsOpen(false);
};
const handleGitHub = () => {
window.open("https://github.com/Termix-SSH/Termix", "_blank");
setIsOpen(false);
};
const handleSupport = () => {
window.open("https://github.com/Termix-SSH/Support/issues/new", "_blank");
setIsOpen(false);
};
const handleDiscord = () => {
window.open("https://discord.com/invite/jVQGdvHDrf", "_blank");
setIsOpen(false);
};
const handleDonate = () => {
window.open("https://github.com/sponsors/LukeGus", "_blank");
setIsOpen(false);
};
const handleActivityClick = (item: RecentActivityItem) => {
getSSHHosts().then((hosts) => {
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
if (!host) return;
if (item.type === "terminal") {
addTab({
type: "terminal",
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "file_manager") {
addTab({
type: "file_manager",
title: item.hostName,
hostConfig: host,
});
}
});
setIsOpen(false);
};
const handleHostTerminalClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "terminal", title, hostConfig: host });
setIsOpen(false);
};
const handleHostFileManagerClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "file_manager", title, hostConfig: host });
setIsOpen(false);
};
const handleHostServerDetailsClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "server", title, hostConfig: host });
setIsOpen(false);
};
const handleHostEditClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "ssh_manager",
title: t("commandPalette.hostManager"),
hostConfig: host,
initialTab: "add_host",
});
setIsOpen(false);
};
return (
<div
className={cn(
"fixed inset-0 z-50 flex items-center justify-center bg-black/30 transition-opacity duration-200",
!isOpen && "opacity-0 pointer-events-none",
)}
onClick={() => setIsOpen(false)}
>
<Command
className={cn(
"w-3/4 max-w-2xl max-h-[60vh] rounded-lg border-2 border-dark-border shadow-md flex flex-col",
"transition-all duration-200 ease-out",
!isOpen && "scale-95 opacity-0",
)}
onClick={(e) => e.stopPropagation()}
>
<CommandInput
ref={inputRef}
placeholder={t("commandPalette.searchPlaceholder")}
/>
<CommandList
key={recentActivity.length}
className="w-full h-auto flex-grow overflow-y-auto"
style={{ maxHeight: "inherit" }}
>
{recentActivity.length > 0 && (
<>
<CommandGroup heading={t("commandPalette.recentActivity")}>
{recentActivity.map((item, index) => (
<CommandItem
key={`recent-activity-${index}-${item.type}-${item.hostId}-${item.timestamp}`}
value={`recent-activity-${index}-${item.hostName}-${item.type}`}
onSelect={() => handleActivityClick(item)}
>
{item.type === "terminal" ? <Terminal /> : <FolderOpen />}
<span>{item.hostName}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading={t("commandPalette.navigation")}>
<CommandItem onSelect={handleAddHost}>
<Server />
<span>{t("commandPalette.addHost")}</span>
</CommandItem>
<CommandItem onSelect={handleAddCredential}>
<Key />
<span>{t("commandPalette.addCredential")}</span>
</CommandItem>
<CommandItem onSelect={handleOpenAdminSettings}>
<Settings />
<span>{t("commandPalette.adminSettings")}</span>
</CommandItem>
<CommandItem onSelect={handleOpenUserProfile}>
<User />
<span>{t("commandPalette.userProfile")}</span>
</CommandItem>
<CommandItem onSelect={handleOpenUpdateLog}>
<GrUpdate />
<span>{t("commandPalette.updateLog")}</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={t("commandPalette.hosts")}>
{hosts.map((host, index) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
return (
<CommandItem
key={`host-${index}-${host.id}`}
value={`host-${index}-${title}-${host.id}`}
onSelect={() => {
if (host.enableTerminal) {
handleHostTerminalClick(host);
}
}}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<Server className="h-4 w-4" />
<span>{title}</span>
</div>
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="!px-2 h-7 border-1 border-dark-border"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="right"
className="w-56 bg-dark-bg border-dark-border text-white"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostServerDetailsClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Server className="h-4 w-4" />
<span className="flex-1">{t("commandPalette.openServerDetails")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostFileManagerClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<FolderOpen className="h-4 w-4" />
<span className="flex-1">{t("commandPalette.openFileManager")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostEditClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Pencil className="h-4 w-4" />
<span className="flex-1">{t("commandPalette.edit")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={t("commandPalette.links")}>
<CommandItem onSelect={handleGitHub}>
<Github />
<span>{t("commandPalette.github")}</span>
</CommandItem>
<CommandItem onSelect={handleSupport}>
<BiSupport />
<span>{t("commandPalette.support")}</span>
</CommandItem>
<CommandItem onSelect={handleDiscord}>
<BsDiscord />
<span>{t("commandPalette.discord")}</span>
</CommandItem>
<CommandItem onSelect={handleDonate}>
<BiMoney />
<span>{t("commandPalette.donate")}</span>
</CommandItem>
</CommandGroup>
</CommandList>
<div className="border-t border-dark-border px-4 py-2 bg-dark-hover/50 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t("commandPalette.press")}</span>
<KbdGroup>
<Kbd>Shift</Kbd>
<Kbd>Shift</Kbd>
</KbdGroup>
<span>{t("commandPalette.toToggle")}</span>
</div>
<div className="flex items-center gap-2">
<span>{t("commandPalette.close")}</span>
<Kbd>Esc</Kbd>
</div>
</div>
</Command>
</div>
);
}

View File

@@ -85,13 +85,15 @@ export function Dashboard({
>([]);
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
let sidebarState: "expanded" | "collapsed" = "expanded";
try {
const sidebar = useSidebar();
sidebarState = sidebar.state;
} catch {}
} catch (error) {
console.error("Dashboard operation failed:", error);
}
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
@@ -173,7 +175,9 @@ export function Dashboard({
if (Array.isArray(tunnelConnections)) {
totalTunnelsCount += tunnelConnections.length;
}
} catch {}
} catch (error) {
console.error("Dashboard operation failed:", error);
}
}
}
setTotalTunnels(totalTunnelsCount);
@@ -264,6 +268,7 @@ export function Dashboard({
const handleAddHost = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_host" });
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
@@ -278,6 +283,7 @@ export function Dashboard({
const handleAddCredential = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
@@ -523,7 +529,7 @@ export function Dashboard({
</div>
</div>
</div>
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<div className="flex flex-row items-center justify-between mb-3 mt-1">
<p className="text-xl font-semibold flex flex-row items-center">
@@ -543,7 +549,7 @@ export function Dashboard({
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm">
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingRecentActivity")}</span>
</div>
@@ -575,7 +581,7 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-row flex-1 gap-4 min-h-0">
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<FastForward className="mr-3" />
@@ -639,7 +645,7 @@ export function Dashboard({
</div>
</div>
</div>
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<ChartLine className="mr-3" />
@@ -649,7 +655,7 @@ export function Dashboard({
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{serverStatsLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm">
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingServerStats")}</span>
</div>
@@ -671,7 +677,7 @@ export function Dashboard({
{server.name}
</p>
</div>
<div className="flex flex-row justify-between text-xs text-muted-foreground">
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
<span>
{t("dashboard.cpu")}:{" "}
{server.cpu !== null

View File

@@ -16,6 +16,8 @@ import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog";
import { CompressDialog } from "./components/CompressDialog";
import {
Upload,
FolderPlus,
@@ -49,6 +51,9 @@ import {
addFolderShortcut,
getPinnedFiles,
logActivity,
changeSSHPermissions,
extractSSHArchive,
compressSSHFiles,
} from "@/ui/main-axios.ts";
import type { SidebarItem } from "./FileManagerSidebar";
@@ -146,6 +151,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
const [permissionsDialogFile, setPermissionsDialogFile] = useState<FileItem | null>(null);
const [compressDialogFiles, setCompressDialogFiles] = useState<FileItem[]>([]);
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
@@ -1058,6 +1065,76 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
}
async function handleExtractArchive(file: FileItem) {
if (!sshSessionId) return;
try {
await ensureSSHConnection();
toast.info(t("fileManager.extractingArchive", { name: file.name }));
await extractSSHArchive(
sshSessionId,
file.path,
undefined,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(t("fileManager.archiveExtractedSuccessfully", { name: file.name }));
// Refresh directory to show extracted files
handleRefreshDirectory();
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`,
);
}
}
function handleOpenCompressDialog(files: FileItem[]) {
setCompressDialogFiles(files);
}
async function handleCompress(archiveName: string, format: string) {
if (!sshSessionId || compressDialogFiles.length === 0) return;
try {
await ensureSSHConnection();
const paths = compressDialogFiles.map(f => f.path);
const fileNames = compressDialogFiles.map(f => f.name);
toast.info(t("fileManager.compressingFiles", {
count: fileNames.length,
name: archiveName
}));
await compressSSHFiles(
sshSessionId,
paths,
archiveName,
format,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(t("fileManager.filesCompressedSuccessfully", {
name: archiveName
}));
// Refresh directory to show compressed file
handleRefreshDirectory();
clearSelection();
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`,
);
}
}
async function handleUndo() {
if (undoHistory.length === 0) {
toast.info(t("fileManager.noUndoableActions"));
@@ -1180,6 +1257,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(file);
}
function handleOpenPermissionsDialog(file: FileItem) {
setPermissionsDialogFile(file);
}
async function handleSavePermissions(file: FileItem, permissions: string) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
try {
await changeSSHPermissions(
sshSessionId,
file.path,
permissions,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(t("fileManager.permissionsChangedSuccessfully"));
await handleRefreshDirectory();
} catch (error: unknown) {
console.error("Failed to change permissions:", error);
toast.error(t("fileManager.failedToChangePermissions"));
throw error;
}
}
async function ensureSSHConnection() {
if (!sshSessionId || !currentHost || isReconnecting) return;
@@ -1928,6 +2033,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
createIntent={createIntent}
onConfirmCreate={handleConfirmCreate}
onCancelCreate={handleCancelCreate}
onNewFile={handleCreateNewFile}
onNewFolder={handleCreateNewFolder}
/>
<FileManagerContextMenu
@@ -1966,10 +2073,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onAddShortcut={handleAddShortcut}
isPinned={isPinnedFile}
currentPath={currentPath}
onProperties={handleOpenPermissionsDialog}
onExtractArchive={handleExtractArchive}
onCompress={handleOpenCompressDialog}
/>
</div>
</div>
<CompressDialog
open={compressDialogFiles.length > 0}
onOpenChange={(open) => !open && setCompressDialogFiles([])}
fileNames={compressDialogFiles.map(f => f.name)}
onCompress={handleCompress}
/>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
@@ -1991,6 +2108,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}}
/>
)}
<PermissionsDialog
file={permissionsDialogFile}
open={permissionsDialogFile !== null}
onOpenChange={(open) => {
if (!open) setPermissionsDialogFile(null);
}}
onSave={handleSavePermissions}
/>
</div>
);
}

View File

@@ -17,8 +17,10 @@ import {
Play,
Star,
Bookmark,
FileArchive,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
interface FileItem {
name: string;
@@ -59,6 +61,8 @@ interface ContextMenuProps {
onAddShortcut?: (path: string) => void;
isPinned?: (file: FileItem) => boolean;
currentPath?: string;
onExtractArchive?: (file: FileItem) => void;
onCompress?: (files: FileItem[]) => void;
}
interface MenuItem {
@@ -98,12 +102,20 @@ export function FileManagerContextMenu({
onAddShortcut,
isPinned,
currentPath,
onExtractArchive,
onCompress,
}: ContextMenuProps) {
const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y });
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
if (!isVisible) return;
if (!isVisible) {
setIsMounted(false);
return;
}
setIsMounted(true);
const adjustPosition = () => {
const menuWidth = 200;
@@ -182,8 +194,6 @@ export function FileManagerContextMenu({
};
}, [isVisible, x, y, onClose]);
if (!isVisible) return null;
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
@@ -249,6 +259,45 @@ export function FileManagerContextMenu({
});
}
// Add extract option for archive files
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
const fileName = files[0].name.toLowerCase();
const isArchive =
fileName.endsWith(".zip") ||
fileName.endsWith(".tar") ||
fileName.endsWith(".tar.gz") ||
fileName.endsWith(".tgz") ||
fileName.endsWith(".tar.bz2") ||
fileName.endsWith(".tbz2") ||
fileName.endsWith(".tar.xz") ||
fileName.endsWith(".gz") ||
fileName.endsWith(".bz2") ||
fileName.endsWith(".xz") ||
fileName.endsWith(".7z") ||
fileName.endsWith(".rar");
if (isArchive) {
menuItems.push({
icon: <FileArchive className="w-4 h-4" />,
label: t("fileManager.extractArchive"),
action: () => onExtractArchive(files[0]),
shortcut: "Ctrl+E",
});
}
}
// Add compress option for selected files/folders
if (isFileContext && onCompress) {
menuItems.push({
icon: <FileArchive className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.compressFiles")
: t("fileManager.compressFile"),
action: () => onCompress(files),
shortcut: "Ctrl+Shift+C",
});
}
if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
@@ -425,13 +474,38 @@ export function FileManagerContextMenu({
return index > 0 && index < filteredMenuItems.length - 1;
});
const renderShortcut = (shortcut: string) => {
const keys = shortcut.split("+");
if (keys.length === 1) {
return <Kbd>{keys[0]}</Kbd>;
}
return (
<KbdGroup>
{keys.map((key, index) => (
<Kbd key={index}>{key}</Kbd>
))}
</KbdGroup>
);
};
if (!isVisible && !isMounted) return null;
return (
<>
<div className="fixed inset-0 z-[99990]" />
<div
className={cn(
"fixed inset-0 z-[99990] transition-opacity duration-150",
!isMounted && "opacity-0"
)}
/>
<div
data-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"
className={cn(
"fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden",
"transition-all duration-150 ease-out origin-top-left",
isMounted ? "opacity-100 scale-100" : "opacity-0 scale-95"
)}
style={{
left: menuPosition.x,
top: menuPosition.y,
@@ -470,9 +544,9 @@ export function FileManagerContextMenu({
<span className="flex-1">{item.label}</span>
</div>
{item.shortcut && (
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
{item.shortcut}
</span>
<div className="ml-2 flex-shrink-0">
{renderShortcut(item.shortcut)}
</div>
)}
</button>
);

View File

@@ -24,6 +24,7 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "../../../types/index.js";
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
interface CreateIntent {
id: string;
@@ -92,17 +93,37 @@ interface FileManagerGridProps {
createIntent?: CreateIntent | null;
onConfirmCreate?: (name: string) => void;
onCancelCreate?: () => void;
onNewFile?: () => void;
onNewFolder?: () => void;
}
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
const getFileTypeColor = (file: FileItem): string => {
const colorEnabled = localStorage.getItem("fileColorCoding") !== "false";
if (!colorEnabled) {
return "text-muted-foreground";
}
if (file.type === "directory") {
return <Folder className={`${iconClass} text-muted-foreground`} />;
return "text-red-400";
}
if (file.type === "link") {
return <FileSymlink className={`${iconClass} text-muted-foreground`} />;
return "text-green-400";
}
return "text-blue-400";
};
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
const colorClass = getFileTypeColor(file);
if (file.type === "directory") {
return <Folder className={`${iconClass} ${colorClass}`} />;
}
if (file.type === "link") {
return <FileSymlink className={`${iconClass} ${colorClass}`} />;
}
const ext = file.name.split(".").pop()?.toLowerCase();
@@ -111,30 +132,30 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
case "txt":
case "md":
case "readme":
return <FileText className={`${iconClass} text-muted-foreground`} />;
return <FileText className={`${iconClass} ${colorClass}`} />;
case "png":
case "jpg":
case "jpeg":
case "gif":
case "bmp":
case "svg":
return <FileImage className={`${iconClass} text-muted-foreground`} />;
return <FileImage className={`${iconClass} ${colorClass}`} />;
case "mp4":
case "avi":
case "mkv":
case "mov":
return <FileVideo className={`${iconClass} text-muted-foreground`} />;
return <FileVideo className={`${iconClass} ${colorClass}`} />;
case "mp3":
case "wav":
case "flac":
case "ogg":
return <FileAudio className={`${iconClass} text-muted-foreground`} />;
return <FileAudio className={`${iconClass} ${colorClass}`} />;
case "zip":
case "tar":
case "gz":
case "rar":
case "7z":
return <Archive className={`${iconClass} text-muted-foreground`} />;
return <Archive className={`${iconClass} ${colorClass}`} />;
case "js":
case "ts":
case "jsx":
@@ -148,7 +169,7 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
case "rb":
case "go":
case "rs":
return <Code className={`${iconClass} text-muted-foreground`} />;
return <Code className={`${iconClass} ${colorClass}`} />;
case "json":
case "xml":
case "yaml":
@@ -157,9 +178,9 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
case "ini":
case "conf":
case "config":
return <Settings className={`${iconClass} text-muted-foreground`} />;
return <Settings className={`${iconClass} ${colorClass}`} />;
default:
return <File className={`${iconClass} text-muted-foreground`} />;
return <File className={`${iconClass} ${colorClass}`} />;
}
};
@@ -192,6 +213,8 @@ export function FileManagerGrid({
createIntent,
onConfirmCreate,
onCancelCreate,
onNewFile,
onNewFolder,
}: FileManagerGridProps) {
const { t } = useTranslation();
const gridRef = useRef<HTMLDivElement>(null);
@@ -772,6 +795,42 @@ export function FileManagerGrid({
onUndo();
}
break;
case "d":
case "D":
if (
(event.ctrlKey || event.metaKey) &&
selectedFiles.length > 0 &&
onDownload
) {
event.preventDefault();
onDownload(selectedFiles);
}
break;
case "n":
case "N":
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
if (event.shiftKey && onNewFolder) {
onNewFolder();
} else if (!event.shiftKey && onNewFile) {
onNewFile();
}
}
break;
case "u":
case "U":
if ((event.ctrlKey || event.metaKey) && onUpload) {
event.preventDefault();
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (files) onUpload(files);
};
input.click();
}
break;
case "Delete":
if (selectedFiles.length > 0 && onDelete) {
onDelete(selectedFiles);
@@ -783,6 +842,12 @@ export function FileManagerGrid({
onStartEdit(selectedFiles[0]);
}
break;
case "Enter":
if (selectedFiles.length === 1) {
event.preventDefault();
onFileOpen(selectedFiles[0]);
}
break;
case "y":
case "Y":
if (event.ctrlKey || event.metaKey) {
@@ -807,19 +872,8 @@ export function FileManagerGrid({
onUndo,
]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
<div className="h-full flex flex-col bg-dark-bg overflow-hidden relative">
<div className="flex-shrink-0 border-b border-dark-border">
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
<button
@@ -1003,8 +1057,9 @@ export function FileManagerGrid({
draggable={true}
className={cn(
"group p-3 rounded-lg cursor-pointer",
"hover:bg-accent hover:text-accent-foreground border-2 border-transparent",
isSelected && "bg-primary/20 border-primary",
"transition-all duration-150 ease-out",
"hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] border-2 border-transparent",
isSelected && "bg-primary/20 border-primary ring-2 ring-primary/20",
dragState.target?.path === file.path &&
"bg-muted border-primary border-dashed relative z-10",
dragState.files.some((f) => f.path === file.path) &&
@@ -1092,8 +1147,9 @@ export function FileManagerGrid({
draggable={true}
className={cn(
"flex items-center gap-3 p-2 rounded cursor-pointer",
"transition-all duration-150 ease-out",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-primary/20",
isSelected && "bg-primary/20 ring-2 ring-primary/20",
dragState.target?.path === file.path &&
"bg-muted border-primary border-dashed relative z-10",
dragState.files.some((f) => f.path === file.path) &&
@@ -1264,6 +1320,13 @@ export function FileManagerGrid({
</div>,
document.body,
)}
<LoadingOverlay
visible={isLoading}
minDuration={600}
message={t("common.loading")}
showLogo={true}
/>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslation } from "react-i18next";
interface CompressDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
fileNames: string[];
onCompress: (archiveName: string, format: string) => void;
}
export function CompressDialog({
open,
onOpenChange,
fileNames,
onCompress,
}: CompressDialogProps) {
const { t } = useTranslation();
const [archiveName, setArchiveName] = useState("");
const [format, setFormat] = useState("zip");
useEffect(() => {
if (open && fileNames.length > 0) {
// Generate default archive name
if (fileNames.length === 1) {
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
setArchiveName(baseName);
} else {
setArchiveName("archive");
}
}
}, [open, fileNames]);
const handleCompress = () => {
if (!archiveName.trim()) return;
// Append extension if not already present
let finalName = archiveName.trim();
const extensions: Record<string, string> = {
zip: ".zip",
"tar.gz": ".tar.gz",
"tar.bz2": ".tar.bz2",
"tar.xz": ".tar.xz",
tar: ".tar",
"7z": ".7z",
};
const expectedExtension = extensions[format];
if (expectedExtension && !finalName.endsWith(expectedExtension)) {
finalName += expectedExtension;
}
onCompress(finalName, format);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t("fileManager.compressFiles")}</DialogTitle>
<DialogDescription>
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="archiveName">{t("fileManager.archiveName")}</Label>
<Input
id="archiveName"
value={archiveName}
onChange={(e) => setArchiveName(e.target.value)}
placeholder={t("fileManager.enterArchiveName")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCompress();
}
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="format">{t("fileManager.compressionFormat")}</Label>
<Select value={format} onValueChange={setFormat}>
<SelectTrigger id="format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zip">ZIP (.zip)</SelectItem>
<SelectItem value="tar.gz">TAR.GZ (.tar.gz)</SelectItem>
<SelectItem value="tar.bz2">TAR.BZ2 (.tar.bz2)</SelectItem>
<SelectItem value="tar.xz">TAR.XZ (.tar.xz)</SelectItem>
<SelectItem value="tar">TAR (.tar)</SelectItem>
<SelectItem value="7z">7-Zip (.7z)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md bg-muted p-3">
<p className="text-sm text-muted-foreground mb-2">
{t("fileManager.selectedFiles")}:
</p>
<ul className="text-sm space-y-1">
{fileNames.slice(0, 5).map((name, index) => (
<li key={index} className="truncate">
{name}
</li>
))}
{fileNames.length > 5 && (
<li className="text-muted-foreground italic">
{t("fileManager.andMoreFiles", { count: fileNames.length - 5 })}
</li>
)}
</ul>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleCompress} disabled={!archiveName.trim()}>
{t("fileManager.compress")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,295 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { useTranslation } from "react-i18next";
import { Shield } from "lucide-react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
permissions?: string;
owner?: string;
group?: string;
}
interface PermissionsDialogProps {
file: FileItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (file: FileItem, permissions: string) => Promise<void>;
}
// Parse permissions like "rwxr-xr-x" or "755" to individual bits
const parsePermissions = (perms: string): { owner: number; group: number; other: number } => {
if (!perms) {
return { owner: 0, group: 0, other: 0 };
}
// If numeric format like "755"
if (/^\d{3,4}$/.test(perms)) {
const numStr = perms.slice(-3);
return {
owner: parseInt(numStr[0] || "0", 10),
group: parseInt(numStr[1] || "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 calcBits = (str: string): number => {
let value = 0;
if (str[0] === "r") value += 4;
if (str[1] === "w") value += 2;
if (str[2] === "x") value += 1;
return value;
};
return {
owner: calcBits(cleanPerms.substring(0, 3)),
group: calcBits(cleanPerms.substring(3, 6)),
other: calcBits(cleanPerms.substring(6, 9)),
};
};
// Convert individual bits to numeric format
const toNumeric = (owner: number, group: number, other: number): string => {
return `${owner}${group}${other}`;
};
export function PermissionsDialog({
file,
open,
onOpenChange,
onSave,
}: PermissionsDialogProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const initialPerms = parsePermissions(file?.permissions || "644");
const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0);
const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0);
const [ownerExecute, setOwnerExecute] = useState((initialPerms.owner & 1) !== 0);
const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0);
const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0);
const [groupExecute, setGroupExecute] = useState((initialPerms.group & 1) !== 0);
const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0);
const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0);
const [otherExecute, setOtherExecute] = useState((initialPerms.other & 1) !== 0);
// Reset when file changes
useEffect(() => {
if (file) {
const perms = parsePermissions(file.permissions || "644");
setOwnerRead((perms.owner & 4) !== 0);
setOwnerWrite((perms.owner & 2) !== 0);
setOwnerExecute((perms.owner & 1) !== 0);
setGroupRead((perms.group & 4) !== 0);
setGroupWrite((perms.group & 2) !== 0);
setGroupExecute((perms.group & 1) !== 0);
setOtherRead((perms.other & 4) !== 0);
setOtherWrite((perms.other & 2) !== 0);
setOtherExecute((perms.other & 1) !== 0);
}
}, [file]);
const calculateOctal = (): string => {
const owner = (ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0);
const group = (groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0);
const other = (otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0);
return toNumeric(owner, group, other);
};
const handleSave = async () => {
if (!file) return;
setLoading(true);
try {
const permissions = calculateOctal();
await onSave(file, permissions);
onOpenChange(false);
} catch (error) {
console.error("Failed to update permissions:", error);
} finally {
setLoading(false);
}
};
if (!file) return null;
const octal = calculateOctal();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
{t("fileManager.changePermissions")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("fileManager.changePermissionsDesc")}: <span className="font-mono text-foreground">{file.name}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Current info */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<Label className="text-gray-400">{t("fileManager.currentPermissions")}</Label>
<p className="font-mono text-lg mt-1">{file.permissions || "644"}</p>
</div>
<div>
<Label className="text-gray-400">{t("fileManager.newPermissions")}</Label>
<p className="font-mono text-lg mt-1">{octal}</p>
</div>
</div>
{/* Owner permissions */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("fileManager.owner")} {file.owner && `(${file.owner})`}
</Label>
<div className="flex gap-6 ml-4">
<div className="flex items-center space-x-2">
<Checkbox
id="owner-read"
checked={ownerRead}
onCheckedChange={(checked) => setOwnerRead(checked === true)}
/>
<label htmlFor="owner-read" className="text-sm cursor-pointer">
{t("fileManager.read")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="owner-write"
checked={ownerWrite}
onCheckedChange={(checked) => setOwnerWrite(checked === true)}
/>
<label htmlFor="owner-write" className="text-sm cursor-pointer">
{t("fileManager.write")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="owner-execute"
checked={ownerExecute}
onCheckedChange={(checked) => setOwnerExecute(checked === true)}
/>
<label htmlFor="owner-execute" className="text-sm cursor-pointer">
{t("fileManager.execute")}
</label>
</div>
</div>
</div>
{/* Group permissions */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("fileManager.group")} {file.group && `(${file.group})`}
</Label>
<div className="flex gap-6 ml-4">
<div className="flex items-center space-x-2">
<Checkbox
id="group-read"
checked={groupRead}
onCheckedChange={(checked) => setGroupRead(checked === true)}
/>
<label htmlFor="group-read" className="text-sm cursor-pointer">
{t("fileManager.read")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="group-write"
checked={groupWrite}
onCheckedChange={(checked) => setGroupWrite(checked === true)}
/>
<label htmlFor="group-write" className="text-sm cursor-pointer">
{t("fileManager.write")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="group-execute"
checked={groupExecute}
onCheckedChange={(checked) => setGroupExecute(checked === true)}
/>
<label htmlFor="group-execute" className="text-sm cursor-pointer">
{t("fileManager.execute")}
</label>
</div>
</div>
</div>
{/* Others permissions */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("fileManager.others")}
</Label>
<div className="flex gap-6 ml-4">
<div className="flex items-center space-x-2">
<Checkbox
id="other-read"
checked={otherRead}
onCheckedChange={(checked) => setOtherRead(checked === true)}
/>
<label htmlFor="other-read" className="text-sm cursor-pointer">
{t("fileManager.read")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="other-write"
checked={otherWrite}
onCheckedChange={(checked) => setOtherWrite(checked === true)}
/>
<label htmlFor="other-write" className="text-sm cursor-pointer">
{t("fileManager.write")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="other-execute"
checked={otherExecute}
onCheckedChange={(checked) => setOtherExecute(checked === true)}
/>
<label htmlFor="other-execute" className="text-sm cursor-pointer">
{t("fileManager.execute")}
</label>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -35,28 +35,10 @@ export function HostManager({
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (ignoreNextHostConfigChangeRef.current) {
ignoreNextHostConfigChangeRef.current = false;
return;
if (initialTab) {
setActiveTab(initialTab);
}
if (hostConfig && initialTab === "add_host") {
const currentHostId = hostConfig.id;
if (currentHostId !== lastProcessedHostIdRef.current) {
setEditingHost(hostConfig);
setActiveTab("add_host");
lastProcessedHostIdRef.current = currentHostId;
} else if (
activeTab === "host_viewer" ||
activeTab === "credentials" ||
activeTab === "add_credential"
) {
setEditingHost(hostConfig);
setActiveTab("add_host");
}
}
}, [hostConfig, initialTab]);
}, [initialTab]);
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);

View File

@@ -167,7 +167,9 @@ export function HostManagerEditor({
setFolders(uniqueFolders);
setSshConfigurations(uniqueConfigurations);
} catch {}
} catch (error) {
console.error("Host manager operation failed:", error);
}
};
fetchData();
@@ -196,7 +198,9 @@ export function HostManagerEditor({
setFolders(uniqueFolders);
setSshConfigurations(uniqueConfigurations);
} catch {}
} catch (error) {
console.error("Host manager operation failed:", error);
}
};
window.addEventListener("credentials:changed", handleCredentialChange);
@@ -261,9 +265,18 @@ export function HostManagerEditor({
"uptime",
"processes",
"system",
"login_stats",
]),
)
.default(["cpu", "memory", "disk", "network", "uptime", "system"]),
.default([
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
]),
statusCheckEnabled: z.boolean().default(true),
statusCheckInterval: z.number().min(5).max(3600).default(30),
metricsEnabled: z.boolean().default(true),
@@ -277,6 +290,7 @@ export function HostManagerEditor({
"network",
"uptime",
"system",
"login_stats",
],
statusCheckEnabled: true,
statusCheckInterval: 30,
@@ -1349,15 +1363,15 @@ export function HostManagerEditor({
</AlertDescription>
</Alert>
<h1 className="text-xl font-semibold mt-7">
Terminal Customization
{t("hosts.terminalCustomization")}
</h1>
<Accordion type="multiple" className="w-full">
<AccordionItem value="appearance">
<AccordionTrigger>Appearance</AccordionTrigger>
<AccordionTrigger>{t("hosts.appearance")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Theme Preview
{t("hosts.themePreview")}
</label>
<TerminalPreview
theme={form.watch("terminalConfig.theme")}
@@ -1381,14 +1395,14 @@ export function HostManagerEditor({
name="terminalConfig.theme"
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<FormLabel>{t("hosts.theme")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select theme" />
<SelectValue placeholder={t("hosts.selectTheme")} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -1402,7 +1416,7 @@ export function HostManagerEditor({
</SelectContent>
</Select>
<FormDescription>
Choose a color theme for the terminal
{t("hosts.chooseColorTheme")}
</FormDescription>
</FormItem>
)}
@@ -1413,14 +1427,14 @@ export function HostManagerEditor({
name="terminalConfig.fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>Font Family</FormLabel>
<FormLabel>{t("hosts.fontFamily")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select font" />
<SelectValue placeholder={t("hosts.selectFont")} />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -1435,7 +1449,7 @@ export function HostManagerEditor({
</SelectContent>
</Select>
<FormDescription>
Select the font to use in the terminal
{t("hosts.selectFontDesc")}
</FormDescription>
</FormItem>
)}
@@ -1446,7 +1460,7 @@ export function HostManagerEditor({
name="terminalConfig.fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>Font Size: {field.value}px</FormLabel>
<FormLabel>{t("hosts.fontSizeValue", { value: field.value })}</FormLabel>
<FormControl>
<Slider
min={8}
@@ -1459,7 +1473,7 @@ export function HostManagerEditor({
/>
</FormControl>
<FormDescription>
Adjust the terminal font size
{t("hosts.adjustFontSize")}
</FormDescription>
</FormItem>
)}
@@ -1471,7 +1485,7 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
Letter Spacing: {field.value}px
{t("hosts.letterSpacingValue", { value: field.value })}
</FormLabel>
<FormControl>
<Slider
@@ -1485,7 +1499,7 @@ export function HostManagerEditor({
/>
</FormControl>
<FormDescription>
Adjust spacing between characters
{t("hosts.adjustLetterSpacing")}
</FormDescription>
</FormItem>
)}
@@ -1496,7 +1510,7 @@ export function HostManagerEditor({
name="terminalConfig.lineHeight"
render={({ field }) => (
<FormItem>
<FormLabel>Line Height: {field.value}</FormLabel>
<FormLabel>{t("hosts.lineHeightValue", { value: field.value })}</FormLabel>
<FormControl>
<Slider
min={1}
@@ -1509,7 +1523,7 @@ export function HostManagerEditor({
/>
</FormControl>
<FormDescription>
Adjust spacing between lines
{t("hosts.adjustLineHeight")}
</FormDescription>
</FormItem>
)}
@@ -1520,26 +1534,26 @@ export function HostManagerEditor({
name="terminalConfig.cursorStyle"
render={({ field }) => (
<FormItem>
<FormLabel>Cursor Style</FormLabel>
<FormLabel>{t("hosts.cursorStyle")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select cursor style" />
<SelectValue placeholder={t("hosts.selectCursorStyle")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="block">Block</SelectItem>
<SelectItem value="block">{t("hosts.cursorStyleBlock")}</SelectItem>
<SelectItem value="underline">
Underline
{t("hosts.cursorStyleUnderline")}
</SelectItem>
<SelectItem value="bar">Bar</SelectItem>
<SelectItem value="bar">{t("hosts.cursorStyleBar")}</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the cursor appearance
{t("hosts.chooseCursorAppearance")}
</FormDescription>
</FormItem>
)}
@@ -1551,9 +1565,9 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Cursor Blink</FormLabel>
<FormLabel>{t("hosts.cursorBlink")}</FormLabel>
<FormDescription>
Enable cursor blinking animation
{t("hosts.enableCursorBlink")}
</FormDescription>
</div>
<FormControl>
@@ -1569,7 +1583,7 @@ export function HostManagerEditor({
</AccordionItem>
<AccordionItem value="behavior">
<AccordionTrigger>Behavior</AccordionTrigger>
<AccordionTrigger>{t("hosts.behavior")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={form.control}
@@ -1577,7 +1591,7 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
Scrollback Buffer: {field.value} lines
{t("hosts.scrollbackBufferValue", { value: field.value })}
</FormLabel>
<FormControl>
<Slider
@@ -1591,7 +1605,7 @@ export function HostManagerEditor({
/>
</FormControl>
<FormDescription>
Number of lines to keep in scrollback history
{t("hosts.scrollbackBufferDesc")}
</FormDescription>
</FormItem>
)}
@@ -1602,30 +1616,25 @@ export function HostManagerEditor({
name="terminalConfig.bellStyle"
render={({ field }) => (
<FormItem>
<FormLabel>Bell Style</FormLabel>
<FormLabel>{t("hosts.bellStyle")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select bell style" />
<SelectValue placeholder={t("hosts.selectBellStyle")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="sound">Sound</SelectItem>
<SelectItem value="visual">Visual</SelectItem>
<SelectItem value="both">Both</SelectItem>
<SelectItem value="none">{t("hosts.bellStyleNone")}</SelectItem>
<SelectItem value="sound">{t("hosts.bellStyleSound")}</SelectItem>
<SelectItem value="visual">{t("hosts.bellStyleVisual")}</SelectItem>
<SelectItem value="both">{t("hosts.bellStyleBoth")}</SelectItem>
</SelectContent>
</Select>
<FormDescription>
How to handle terminal bell (BEL character,
\x07). Programs trigger this when completing
tasks, encountering errors, or for
notifications. "Sound" plays an audio beep,
"Visual" flashes the screen briefly, "Both" does
both, "None" disables bell alerts.
{t("hosts.bellStyleDesc")}
</FormDescription>
</FormItem>
)}
@@ -1637,9 +1646,9 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Right Click Selects Word</FormLabel>
<FormLabel>{t("hosts.rightClickSelectsWord")}</FormLabel>
<FormDescription>
Right-clicking selects the word under cursor
{t("hosts.rightClickSelectsWordDesc")}
</FormDescription>
</div>
<FormControl>
@@ -1657,24 +1666,24 @@ export function HostManagerEditor({
name="terminalConfig.fastScrollModifier"
render={({ field }) => (
<FormItem>
<FormLabel>Fast Scroll Modifier</FormLabel>
<FormLabel>{t("hosts.fastScrollModifier")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select modifier" />
<SelectValue placeholder={t("hosts.selectModifier")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="alt">Alt</SelectItem>
<SelectItem value="ctrl">Ctrl</SelectItem>
<SelectItem value="shift">Shift</SelectItem>
<SelectItem value="alt">{t("hosts.modifierAlt")}</SelectItem>
<SelectItem value="ctrl">{t("hosts.modifierCtrl")}</SelectItem>
<SelectItem value="shift">{t("hosts.modifierShift")}</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Modifier key for fast scrolling
{t("hosts.fastScrollModifierDesc")}
</FormDescription>
</FormItem>
)}
@@ -1686,7 +1695,7 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
Fast Scroll Sensitivity: {field.value}
{t("hosts.fastScrollSensitivityValue", { value: field.value })}
</FormLabel>
<FormControl>
<Slider
@@ -1700,7 +1709,7 @@ export function HostManagerEditor({
/>
</FormControl>
<FormDescription>
Scroll speed multiplier when modifier is held
{t("hosts.fastScrollSensitivityDesc")}
</FormDescription>
</FormItem>
)}
@@ -1712,7 +1721,7 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
Minimum Contrast Ratio: {field.value}
{t("hosts.minimumContrastRatioValue", { value: field.value })}
</FormLabel>
<FormControl>
<Slider
@@ -1726,8 +1735,7 @@ export function HostManagerEditor({
/>
</FormControl>
<FormDescription>
Automatically adjust colors for better
readability
{t("hosts.minimumContrastRatioDesc")}
</FormDescription>
</FormItem>
)}
@@ -1736,7 +1744,7 @@ export function HostManagerEditor({
</AccordionItem>
<AccordionItem value="advanced">
<AccordionTrigger>Advanced</AccordionTrigger>
<AccordionTrigger>{t("hosts.advanced")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={form.control}
@@ -1744,10 +1752,9 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>SSH Agent Forwarding</FormLabel>
<FormLabel>{t("hosts.sshAgentForwarding")}</FormLabel>
<FormDescription>
Forward SSH authentication agent to remote
host
{t("hosts.sshAgentForwardingDesc")}
</FormDescription>
</div>
<FormControl>
@@ -1765,27 +1772,27 @@ export function HostManagerEditor({
name="terminalConfig.backspaceMode"
render={({ field }) => (
<FormItem>
<FormLabel>Backspace Mode</FormLabel>
<FormLabel>{t("hosts.backspaceMode")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select backspace mode" />
<SelectValue placeholder={t("hosts.selectBackspaceMode")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="normal">
Normal (DEL)
{t("hosts.backspaceModeNormal")}
</SelectItem>
<SelectItem value="control-h">
Control-H (^H)
{t("hosts.backspaceModeControlH")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Backspace key behavior for compatibility
{t("hosts.backspaceModeDesc")}
</FormDescription>
</FormItem>
)}
@@ -1796,7 +1803,7 @@ export function HostManagerEditor({
name="terminalConfig.startupSnippetId"
render={({ field }) => (
<FormItem>
<FormLabel>Startup Snippet</FormLabel>
<FormLabel>{t("hosts.startupSnippet")}</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(
@@ -1808,13 +1815,13 @@ export function HostManagerEditor({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select snippet" />
<SelectValue placeholder={t("hosts.selectSnippet")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<div className="px-2 pb-2 sticky top-0 bg-popover z-10">
<Input
placeholder="Search snippets..."
placeholder={t("hosts.searchSnippets")}
value={snippetSearch}
onChange={(e) =>
setSnippetSearch(e.target.value)
@@ -1825,7 +1832,7 @@ export function HostManagerEditor({
/>
</div>
<div className="max-h-[200px] overflow-y-auto">
<SelectItem value="none">None</SelectItem>
<SelectItem value="none">{t("hosts.snippetNone")}</SelectItem>
{snippets
.filter((snippet) =>
snippet.name
@@ -2607,6 +2614,7 @@ export function HostManagerEditor({
"uptime",
"processes",
"system",
"login_stats",
] as const
).map((widget) => (
<div
@@ -2646,6 +2654,8 @@ export function HostManagerEditor({
t("serverStats.processes")}
{widget === "system" &&
t("serverStats.systemInfo")}
{widget === "login_stats" &&
t("serverStats.loginStats")}
</label>
</div>
))}

View File

@@ -22,10 +22,15 @@ import {
updateSSHHost,
renameFolder,
exportSSHHostWithCredentials,
getSSHFolders,
updateFolderMetadata,
deleteAllHostsInFolder,
getServerStatusById,
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import {
Edit,
Trash2,
@@ -45,16 +50,31 @@ import {
Copy,
Activity,
Clock,
Palette,
Trash,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
FolderOpen,
} from "lucide-react";
import type {
SSHHost,
SSHFolder,
SSHManagerHostViewerProps,
} from "../../../../types/index.js";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { FolderEditDialog } from "./components/FolderEditDialog";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { addTab } = useTabs();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -65,13 +85,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const [editingFolder, setEditingFolder] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState("");
const [operationLoading, setOperationLoading] = useState(false);
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(new Map());
const [editingFolderAppearance, setEditingFolderAppearance] = useState<string | null>(null);
const [serverStatuses, setServerStatuses] = useState<Map<number, "online" | "offline" | "degraded">>(new Map());
const dragCounter = useRef(0);
useEffect(() => {
fetchHosts();
fetchFolderMetadata();
const handleHostsRefresh = () => {
fetchHosts();
fetchFolderMetadata();
};
window.addEventListener("hosts:refresh", handleHostsRefresh);
@@ -116,6 +141,152 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}
};
const fetchFolderMetadata = async () => {
try {
const folders = await getSSHFolders();
const metadataMap = new Map<string, SSHFolder>();
folders.forEach((folder) => {
metadataMap.set(folder.name, folder);
});
setFolderMetadata(metadataMap);
} catch (error) {
console.error("Failed to fetch folder metadata:", error);
}
};
const handleSaveFolderAppearance = async (folderName: string, color: string, icon: string) => {
try {
await updateFolderMetadata(folderName, color, icon);
toast.success(t("hosts.folderAppearanceUpdated"));
await fetchFolderMetadata();
window.dispatchEvent(new CustomEvent("folders:changed"));
} catch (error) {
console.error("Failed to update folder appearance:", error);
toast.error(t("hosts.failedToUpdateFolderAppearance"));
}
};
const handleDeleteAllHostsInFolder = async (folderName: string) => {
const hostsInFolder = hostsByFolder[folderName] || [];
confirmWithToast(
t("hosts.confirmDeleteAllHostsInFolder", {
folder: folderName,
count: hostsInFolder.length,
}),
async () => {
try {
const result = await deleteAllHostsInFolder(folderName);
toast.success(
t("hosts.allHostsInFolderDeleted", {
folder: folderName,
count: result.deletedCount,
})
);
await fetchHosts();
await fetchFolderMetadata();
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
refreshServerPolling();
} catch (error) {
console.error("Failed to delete hosts in folder:", error);
toast.error(t("hosts.failedToDeleteHostsInFolder"));
}
},
"destructive",
);
};
useEffect(() => {
if (hosts.length === 0) return;
const statusIntervals: NodeJS.Timeout[] = [];
const statusCancelled: boolean[] = [];
hosts.forEach((host, index) => {
const statsConfig = (() => {
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return DEFAULT_STATS_CONFIG;
}
})();
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
if (!shouldShowStatus) {
setServerStatuses((prev) => {
const next = new Map(prev);
next.set(host.id, "offline");
return next;
});
return;
}
const fetchStatus = async () => {
try {
const res = await getServerStatusById(host.id);
if (!statusCancelled[index]) {
setServerStatuses((prev) => {
const next = new Map(prev);
next.set(host.id, res?.status === "online" ? "online" : "offline");
return next;
});
}
} catch (error: unknown) {
if (!statusCancelled[index]) {
const err = error as { response?: { status?: number } };
let status: "online" | "offline" | "degraded" = "offline";
if (err?.response?.status === 504) {
status = "degraded";
}
setServerStatuses((prev) => {
const next = new Map(prev);
next.set(host.id, status);
return next;
});
}
}
};
fetchStatus();
const intervalId = setInterval(fetchStatus, 10000);
statusIntervals.push(intervalId);
});
return () => {
statusCancelled.fill(true);
statusIntervals.forEach((interval) => clearInterval(interval));
};
}, [hosts]);
const getFolderIcon = (folderName: string) => {
const metadata = folderMetadata.get(folderName);
if (!metadata?.icon) return Folder;
const iconMap: Record<string, React.ComponentType> = {
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
};
return iconMap[metadata.icon] || Folder;
};
const getFolderColor = (folderName: string) => {
const metadata = folderMetadata.get(folderName);
return metadata?.color;
};
const handleDelete = async (hostId: number, hostName: string) => {
confirmWithToast(
t("hosts.confirmDelete", { name: hostName }),
@@ -854,7 +1025,16 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<AccordionItem value={folder} className="border-none">
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2 flex-1">
<Folder className="h-4 w-4" />
{(() => {
const FolderIcon = getFolderIcon(folder);
const folderColor = getFolderColor(folder);
return (
<FolderIcon
className="h-4 w-4"
style={folderColor ? { color: folderColor } : undefined}
/>
);
})()}
{editingFolder === folder ? (
<div
className="flex items-center gap-2"
@@ -935,6 +1115,50 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<Badge variant="secondary" className="text-xs">
{folderHosts.length}
</Badge>
{folder !== t("hosts.uncategorized") && (
<div className="flex items-center gap-1 ml-auto">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setEditingFolderAppearance(folder);
}}
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 transition-opacity"
>
<Palette className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("hosts.editFolderAppearance")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDeleteAllHostsInFolder(folder);
}}
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 hover:text-red-400 transition-all"
>
<Trash className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("hosts.deleteAllHostsInFolder")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
</AccordionTrigger>
<AccordionContent className="p-2">
@@ -957,6 +1181,28 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{(() => {
const statsConfig = (() => {
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return DEFAULT_STATS_CONFIG;
}
})();
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
const serverStatus = serverStatuses.get(host.id) || "degraded";
return shouldShowStatus ? (
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
) : null;
})()}
{host.pin && (
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
)}
@@ -1179,6 +1425,76 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
})()}
</div>
</div>
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
{host.enableTerminal && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "terminal", title, hostConfig: host });
}}
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
>
<Terminal className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Terminal</p>
</TooltipContent>
</Tooltip>
)}
{host.enableFileManager && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "file_manager", title, hostConfig: host });
}}
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open File Manager</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "server", title, hostConfig: host });
}}
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
>
<Server className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Server Details</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
@@ -1202,6 +1518,22 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
))}
</div>
</ScrollArea>
{editingFolderAppearance && (
<FolderEditDialog
folderName={editingFolderAppearance}
currentColor={getFolderColor(editingFolderAppearance)}
currentIcon={folderMetadata.get(editingFolderAppearance)?.icon}
open={editingFolderAppearance !== null}
onOpenChange={(open) => {
if (!open) setEditingFolderAppearance(null);
}}
onSave={async (color, icon) => {
await handleSaveFolderAppearance(editingFolderAppearance, color, icon);
setEditingFolderAppearance(null);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,189 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useTranslation } from "react-i18next";
import {
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
} from "lucide-react";
interface FolderEditDialogProps {
folderName: string;
currentColor?: string;
currentIcon?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (color: string, icon: string) => Promise<void>;
}
const AVAILABLE_COLORS = [
{ value: "#ef4444", label: "Red" },
{ value: "#f97316", label: "Orange" },
{ value: "#eab308", label: "Yellow" },
{ value: "#22c55e", label: "Green" },
{ value: "#3b82f6", label: "Blue" },
{ value: "#a855f7", label: "Purple" },
{ value: "#ec4899", label: "Pink" },
{ value: "#6b7280", label: "Gray" },
];
const AVAILABLE_ICONS = [
{ value: "Folder", label: "Folder", Icon: Folder },
{ value: "Server", label: "Server", Icon: Server },
{ value: "Cloud", label: "Cloud", Icon: Cloud },
{ value: "Database", label: "Database", Icon: Database },
{ value: "Box", label: "Box", Icon: Box },
{ value: "Package", label: "Package", Icon: Package },
{ value: "Layers", label: "Layers", Icon: Layers },
{ value: "Archive", label: "Archive", Icon: Archive },
{ value: "HardDrive", label: "HardDrive", Icon: HardDrive },
{ value: "Globe", label: "Globe", Icon: Globe },
];
export function FolderEditDialog({
folderName,
currentColor,
currentIcon,
open,
onOpenChange,
onSave,
}: FolderEditDialogProps) {
const { t } = useTranslation();
const [selectedColor, setSelectedColor] = useState(currentColor || AVAILABLE_COLORS[0].value);
const [selectedIcon, setSelectedIcon] = useState(currentIcon || AVAILABLE_ICONS[0].value);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setSelectedColor(currentColor || AVAILABLE_COLORS[0].value);
setSelectedIcon(currentIcon || AVAILABLE_ICONS[0].value);
}
}, [open, currentColor, currentIcon]);
const handleSave = async () => {
setLoading(true);
try {
await onSave(selectedColor, selectedIcon);
onOpenChange(false);
} catch (error) {
console.error("Failed to save folder metadata:", error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Folder className="w-5 h-5" />
{t("hosts.editFolderAppearance")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("hosts.editFolderAppearanceDesc")}: <span className="font-mono text-foreground">{folderName}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Color Selection */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("hosts.folderColor")}
</Label>
<div className="grid grid-cols-4 gap-3">
{AVAILABLE_COLORS.map((color) => (
<button
key={color.value}
type="button"
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
selectedColor === color.value
? "border-white shadow-lg scale-105"
: "border-dark-border"
}`}
style={{ backgroundColor: color.value }}
onClick={() => setSelectedColor(color.value)}
title={color.label}
/>
))}
</div>
</div>
{/* Icon Selection */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("hosts.folderIcon")}
</Label>
<div className="grid grid-cols-5 gap-3">
{AVAILABLE_ICONS.map(({ value, label, Icon }) => (
<button
key={value}
type="button"
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
selectedIcon === value
? "border-primary bg-primary/10"
: "border-dark-border bg-dark-bg-darker"
}`}
onClick={() => setSelectedIcon(value)}
title={label}
>
<Icon className="w-6 h-6" />
</button>
))}
</div>
</div>
{/* Preview */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("hosts.preview")}
</Label>
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
{(() => {
const IconComponent = AVAILABLE_ICONS.find(
(i) => i.value === selectedIcon
)?.Icon || Folder;
return (
<IconComponent
className="w-5 h-5"
style={{ color: selectedColor }}
/>
);
})()}
<span className="font-medium">{folderName}</span>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -17,6 +17,7 @@ import {
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets";
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
import {
CpuWidget,
MemoryWidget,
@@ -25,6 +26,7 @@ import {
UptimeWidget,
ProcessesWidget,
SystemWidget,
LoginStatsWidget,
} from "./widgets";
interface HostConfig {
@@ -137,6 +139,11 @@ export function Server({
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "login_stats":
return (
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
);
default:
return null;
}
@@ -437,17 +444,8 @@ export function Server({
<div className="flex-1 overflow-y-auto min-h-0">
{metricsEnabled && showStatsUI && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</span>
</div>
</div>
) : !metrics && serverStatus === "offline" ? (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto relative">
{!metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
@@ -470,6 +468,13 @@ export function Server({
))}
</div>
)}
<LoadingOverlay
visible={isLoadingMetrics && !metrics}
minDuration={700}
message={t("serverStats.loadingMetrics")}
showLogo={true}
/>
</div>
)}

View File

@@ -0,0 +1,138 @@
import React from "react";
import { UserCheck, UserX, MapPin, Activity } from "lucide-react";
import { useTranslation } from "react-i18next";
interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
interface LoginStatsMetrics {
recentLogins: LoginRecord[];
failedLogins: LoginRecord[];
totalLogins: number;
uniqueIPs: number;
}
interface ServerMetrics {
login_stats?: LoginStatsMetrics;
}
interface LoginStatsWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function LoginStatsWidget({
metrics,
}: LoginStatsWidgetProps) {
const { t } = useTranslation();
const loginStats = metrics?.login_stats;
const recentLogins = loginStats?.recentLogins || [];
const failedLogins = loginStats?.failedLogins || [];
const totalLogins = loginStats?.totalLogins || 0;
const uniqueIPs = loginStats?.uniqueIPs || 0;
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<UserCheck className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.loginStats")}
</h3>
</div>
<div className="flex flex-col flex-1 min-h-0 gap-3">
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
<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">
<Activity className="h-3 w-3" />
<span>{t("serverStats.totalLogins")}</span>
</div>
<div className="text-xl font-bold text-green-400">{totalLogins}</div>
</div>
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
<MapPin className="h-3 w-3" />
<span>{t("serverStats.uniqueIPs")}</span>
</div>
<div className="text-xl font-bold text-blue-400">{uniqueIPs}</div>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
<div className="flex-shrink-0">
<div className="flex items-center gap-2 mb-1">
<UserCheck className="h-4 w-4 text-green-400" />
<span className="text-sm font-semibold text-gray-300">
{t("serverStats.recentSuccessfulLogins")}
</span>
</div>
{recentLogins.length === 0 ? (
<div className="text-xs text-gray-500 italic p-2">
{t("serverStats.noRecentLoginData")}
</div>
) : (
<div className="space-y-1">
{recentLogins.slice(0, 5).map((login, idx) => (
<div
key={idx}
className="text-xs bg-dark-bg-darker p-2 rounded border border-dark-border/30 flex justify-between items-center"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-green-400 font-mono truncate">
{login.user}
</span>
<span className="text-gray-500">{t("serverStats.from")}</span>
<span className="text-blue-400 font-mono truncate">
{login.ip}
</span>
</div>
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
{new Date(login.time).toLocaleString()}
</span>
</div>
))}
</div>
)}
</div>
{failedLogins.length > 0 && (
<div className="flex-shrink-0">
<div className="flex items-center gap-2 mb-1">
<UserX className="h-4 w-4 text-red-400" />
<span className="text-sm font-semibold text-gray-300">
{t("serverStats.recentFailedAttempts")}
</span>
</div>
<div className="space-y-1">
{failedLogins.slice(0, 3).map((login, idx) => (
<div
key={idx}
className="text-xs bg-red-900/20 p-2 rounded border border-red-500/30 flex justify-between items-center"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-red-400 font-mono truncate">
{login.user}
</span>
<span className="text-gray-500">{t("serverStats.from")}</span>
<span className="text-blue-400 font-mono truncate">
{login.ip}
</span>
</div>
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
{new Date(login.time).toLocaleString()}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -5,3 +5,4 @@ export { NetworkWidget } from "./NetworkWidget";
export { UptimeWidget } from "./UptimeWidget";
export { ProcessesWidget } from "./ProcessesWidget";
export { SystemWidget } from "./SystemWidget";
export { LoginStatsWidget } from "./LoginStatsWidget";

View File

@@ -0,0 +1,67 @@
import React, { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface CommandAutocompleteProps {
suggestions: string[];
selectedIndex: number;
onSelect: (command: string) => void;
position: { top: number; left: number };
visible: boolean;
}
export function CommandAutocomplete({
suggestions,
selectedIndex,
onSelect,
position,
visible,
}: CommandAutocompleteProps) {
const containerRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
// Scroll selected item into view
useEffect(() => {
if (selectedRef.current && containerRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex]);
if (!visible || suggestions.length === 0) {
return null;
}
return (
<div
ref={containerRef}
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg max-h-[240px] overflow-y-auto min-w-[200px] max-w-[600px]"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
"hover:bg-dark-hover",
index === selectedIndex && "bg-blue-500/20 text-blue-400"
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {
// Optional: update selected index on hover
}}
>
{suggestion}
</div>
))}
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50">
Tab/Enter to complete to navigate Esc to close
</div>
</div>
);
}

View File

@@ -0,0 +1,225 @@
import React, { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Search, Clock, X, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
interface CommandHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
commands: string[];
onSelectCommand: (command: string) => void;
onDeleteCommand?: (command: string) => void;
isLoading?: boolean;
}
export function CommandHistoryDialog({
open,
onOpenChange,
commands,
onSelectCommand,
onDeleteCommand,
isLoading = false,
}: CommandHistoryDialogProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
// Filter commands based on search query
const filteredCommands = searchQuery
? commands.filter((cmd) =>
cmd.toLowerCase().includes(searchQuery.toLowerCase())
)
: commands;
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setSearchQuery("");
setSelectedIndex(0);
// Focus search input
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
// Scroll selected item into view
useEffect(() => {
if (selectedRef.current && listRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (filteredCommands.length === 0) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) =>
prev < filteredCommands.length - 1 ? prev + 1 : prev
);
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0));
break;
case "Enter":
e.preventDefault();
if (filteredCommands[selectedIndex]) {
onSelectCommand(filteredCommands[selectedIndex]);
onOpenChange(false);
}
break;
case "Escape":
e.preventDefault();
onOpenChange(false);
break;
}
};
const handleSelect = (command: string) => {
onSelectCommand(command);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Command History
</DialogTitle>
</DialogHeader>
<div className="px-6 pb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search commands... (↑↓ to navigate, Enter to select)"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
className="pl-10 pr-10"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => {
setSearchQuery("");
inputRef.current?.focus();
}}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<ScrollArea ref={listRef} className="h-[400px] px-6 pb-6">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
Loading history...
</div>
</div>
) : filteredCommands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
{searchQuery ? (
<>
<Search className="h-12 w-12 mb-2 opacity-20" />
<p>No commands found matching "{searchQuery}"</p>
</>
) : (
<>
<Clock className="h-12 w-12 mb-2 opacity-20" />
<p>No command history yet</p>
<p className="text-sm">Execute commands to build your history</p>
</>
)}
</div>
) : (
<div className="space-y-1">
{filteredCommands.map((command, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-4 py-2.5 rounded-md transition-colors group",
"font-mono text-sm flex items-center justify-between gap-2",
"hover:bg-accent",
index === selectedIndex && "bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/50"
)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span
className="flex-1 cursor-pointer"
onClick={() => handleSelect(command)}
>
{command}
</span>
{onDeleteCommand && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
onDeleteCommand(command);
}}
title="Delete command"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</div>
)}
</ScrollArea>
<div className="px-6 py-3 border-t border-border bg-muted/30">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded"></kbd> Navigate
</span>
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">Enter</kbd> Select
</span>
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">Esc</kbd> Close
</span>
</div>
<span>
{filteredCommands.length} command{filteredCommands.length !== 1 ? "s" : ""}
</span>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,6 +4,7 @@ import {
useState,
useImperativeHandle,
forwardRef,
useCallback,
} from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
@@ -26,6 +27,11 @@ import {
TERMINAL_FONTS,
} from "@/constants/terminal-themes";
import type { TerminalConfig } from "@/types";
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
import { useCommandHistory } from "@/ui/hooks/useCommandHistory";
import { CommandHistoryDialog } from "./CommandHistoryDialog";
import { CommandAutocomplete } from "./CommandAutocomplete";
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
interface HostConfig {
id?: number;
@@ -122,6 +128,94 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const isConnectingRef = useRef(false);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activityLoggedRef = useRef(false);
const keyHandlerAttachedRef = useRef(false);
// Command history tracking (Stage 1)
const { trackInput, getCurrentCommand, updateCurrentCommand } = useCommandTracker({
hostId: hostConfig.id,
enabled: true,
onCommandExecuted: (command) => {
// Add to autocomplete history (Stage 3)
if (!autocompleteHistory.current.includes(command)) {
autocompleteHistory.current = [command, ...autocompleteHistory.current];
}
},
});
// Create refs for callbacks to avoid triggering useEffect re-runs
const getCurrentCommandRef = useRef(getCurrentCommand);
const updateCurrentCommandRef = useRef(updateCurrentCommand);
useEffect(() => {
getCurrentCommandRef.current = getCurrentCommand;
updateCurrentCommandRef.current = updateCurrentCommand;
}, [getCurrentCommand, updateCurrentCommand]);
// Real-time autocomplete (Stage 3)
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<string[]>([]);
const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = useState(0);
const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 });
const autocompleteHistory = useRef<string[]>([]);
const currentAutocompleteCommand = useRef<string>("");
// Refs for accessing current state in event handlers
const showAutocompleteRef = useRef(false);
const autocompleteSuggestionsRef = useRef<string[]>([]);
const autocompleteSelectedIndexRef = useRef(0);
// Command history dialog (Stage 2)
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
// Load command history when dialog opens
useEffect(() => {
if (showHistoryDialog && hostConfig.id) {
setIsLoadingHistory(true);
import("@/ui/main-axios.ts")
.then((module) => module.getCommandHistory(hostConfig.id!))
.then((history) => {
setCommandHistory(history);
})
.catch((error) => {
console.error("Failed to load command history:", error);
setCommandHistory([]);
})
.finally(() => {
setIsLoadingHistory(false);
});
}
}, [showHistoryDialog, hostConfig.id]);
// Load command history for autocomplete on mount (Stage 3)
useEffect(() => {
if (hostConfig.id) {
import("@/ui/main-axios.ts")
.then((module) => module.getCommandHistory(hostConfig.id!))
.then((history) => {
autocompleteHistory.current = history;
})
.catch((error) => {
console.error("Failed to load autocomplete history:", error);
autocompleteHistory.current = [];
});
}
}, [hostConfig.id]);
// Sync autocomplete state to refs for event handlers
useEffect(() => {
showAutocompleteRef.current = showAutocomplete;
}, [showAutocomplete]);
useEffect(() => {
autocompleteSuggestionsRef.current = autocompleteSuggestions;
}, [autocompleteSuggestions]);
useEffect(() => {
autocompleteSelectedIndexRef.current = autocompleteSelectedIndex;
}, [autocompleteSelectedIndex]);
const activityLoggingRef = useRef(false);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
@@ -189,7 +283,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal as { refresh?: (start: number, end: number) => void }
).refresh(0, terminal.rows - 1);
}
} catch {}
} catch (error) {
console.error("Terminal operation failed:", error);
}
}
function performFit() {
@@ -331,7 +427,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
scheduleNotify(cols, rows);
hardRefresh();
}
} catch {}
} catch (error) {
console.error("Terminal operation failed:", error);
}
},
refresh: () => hardRefresh(),
}),
@@ -507,6 +605,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}),
);
terminal.onData((data) => {
// Track command input for history (Stage 1)
trackInput(data);
// Send input to server
ws.send(JSON.stringify({ type: "input", data }));
});
@@ -738,7 +839,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
await navigator.clipboard.writeText(text);
return;
}
} catch {}
} catch (error) {
console.error("Terminal operation failed:", error);
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
@@ -758,10 +861,92 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText();
}
} catch {}
} catch (error) {
console.error("Terminal operation failed:", error);
}
return "";
}
// Handle command selection from history dialog (Stage 2)
const handleSelectCommand = useCallback(
(command: string) => {
if (!terminal || !webSocketRef.current) return;
// Send the command to the terminal
// Simulate typing the command character by character
for (const char of command) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char })
);
}
// Return focus to terminal after selecting command
setTimeout(() => {
terminal.focus();
}, 100);
},
[terminal]
);
// Handle autocomplete selection (mouse click)
const handleAutocompleteSelect = useCallback(
(selectedCommand: string) => {
if (!webSocketRef.current) return;
const currentCmd = currentAutocompleteCommand.current;
const completion = selectedCommand.substring(currentCmd.length);
// Send completion characters to server
for (const char of completion) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char })
);
}
// Update current command tracker
updateCurrentCommand(selectedCommand);
// Close autocomplete
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
// Return focus to terminal
setTimeout(() => {
terminal?.focus();
}, 50);
console.log(`[Autocomplete] ${currentCmd}${selectedCommand}`);
},
[terminal, updateCurrentCommand]
);
// Handle command deletion from history dialog
const handleDeleteCommand = useCallback(
async (command: string) => {
if (!hostConfig.id) return;
try {
// Call API to delete command
const { deleteCommandFromHistory } = await import("@/ui/main-axios.ts");
await deleteCommandFromHistory(hostConfig.id, command);
// Update local state
setCommandHistory((prev) => prev.filter((cmd) => cmd !== command));
// Update autocomplete history
autocompleteHistory.current = autocompleteHistory.current.filter(
(cmd) => cmd !== command
);
console.log(`[Terminal] Command deleted from history: ${command}`);
} catch (error) {
console.error("Failed to delete command from history:", error);
}
},
[hostConfig.id]
);
useEffect(() => {
if (!terminal || !xtermRef.current) return;
@@ -855,7 +1040,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const pasteText = await readTextFromClipboard();
if (pasteText) terminal.paste(pasteText);
}
} catch {}
} catch (error) {
console.error("Terminal operation failed:", error);
}
};
element?.addEventListener("contextmenu", handleContextMenu);
@@ -864,6 +1051,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
// Handle Ctrl+R for command history (Stage 2)
if (e.ctrlKey && e.key === "r" && !e.shiftKey && !e.altKey && !e.metaKey) {
e.preventDefault();
e.stopPropagation();
setShowHistoryDialog(true);
return false;
}
if (
config.backspaceMode === "control-h" &&
e.key === "Backspace" &&
@@ -952,6 +1147,167 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
};
}, [xtermRef, terminal, hostConfig]);
// Register keyboard handler for autocomplete (Stage 3)
// Registered only once when terminal is created
useEffect(() => {
if (!terminal) return;
const handleCustomKey = (e: KeyboardEvent): boolean => {
// Only handle keydown events, ignore keyup to prevent double triggering
if (e.type !== 'keydown') {
return true;
}
// If autocomplete is showing, handle keys specially
if (showAutocompleteRef.current) {
// Handle Escape to close autocomplete
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
return false;
}
// Handle Arrow keys for autocomplete navigation
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
const currentIndex = autocompleteSelectedIndexRef.current;
const suggestionsLength = autocompleteSuggestionsRef.current.length;
if (e.key === "ArrowDown") {
const newIndex = currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
setAutocompleteSelectedIndex(newIndex);
} else if (e.key === "ArrowUp") {
const newIndex = currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1;
setAutocompleteSelectedIndex(newIndex);
}
return false;
}
// Handle Enter to confirm autocomplete selection
if (e.key === "Enter" && autocompleteSuggestionsRef.current.length > 0) {
e.preventDefault();
e.stopPropagation();
const selectedCommand = autocompleteSuggestionsRef.current[autocompleteSelectedIndexRef.current];
const currentCmd = currentAutocompleteCommand.current;
const completion = selectedCommand.substring(currentCmd.length);
// Send completion characters to server
if (webSocketRef.current?.readyState === 1) {
for (const char of completion) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char })
);
}
}
// Update current command tracker
updateCurrentCommandRef.current(selectedCommand);
// Close autocomplete
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
return false;
}
// Handle Tab to cycle through suggestions
if (e.key === "Tab" && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const currentIndex = autocompleteSelectedIndexRef.current;
const suggestionsLength = autocompleteSuggestionsRef.current.length;
const newIndex = currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
setAutocompleteSelectedIndex(newIndex);
return false;
}
// For any other key while autocomplete is showing, close it and let key through
setShowAutocomplete(false);
setAutocompleteSuggestions([]);
currentAutocompleteCommand.current = "";
return true;
}
// Handle Tab for autocomplete (when autocomplete is not showing)
if (e.key === "Tab" && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const currentCmd = getCurrentCommandRef.current().trim();
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
// Filter commands that start with current input
const matches = autocompleteHistory.current
.filter(cmd =>
cmd.startsWith(currentCmd) &&
cmd !== currentCmd &&
cmd.length > currentCmd.length
)
.slice(0, 10); // Show up to 10 matches
if (matches.length === 1) {
// Only one match - auto-complete directly
const completedCommand = matches[0];
const completion = completedCommand.substring(currentCmd.length);
for (const char of completion) {
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char })
);
}
updateCurrentCommandRef.current(completedCommand);
} else if (matches.length > 1) {
// Multiple matches - show selection list
currentAutocompleteCommand.current = currentCmd;
setAutocompleteSuggestions(matches);
setAutocompleteSelectedIndex(0);
// Calculate position (below or above cursor based on available space)
const cursorY = terminal.buffer.active.cursorY;
const cursorX = terminal.buffer.active.cursorX;
const rect = xtermRef.current?.getBoundingClientRect();
if (rect) {
const cellHeight = terminal.rows > 0 ? rect.height / terminal.rows : 20;
const cellWidth = terminal.cols > 0 ? rect.width / terminal.cols : 10;
// Estimate autocomplete menu height (max-h-[240px] from component)
const menuHeight = 240;
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
const spaceBelow = window.innerHeight - cursorBottomY;
const spaceAbove = rect.top + cursorY * cellHeight;
// Show above cursor if not enough space below
const showAbove = spaceBelow < menuHeight && spaceAbove > spaceBelow;
setAutocompletePosition({
top: showAbove
? rect.top + cursorY * cellHeight - menuHeight
: cursorBottomY,
left: rect.left + cursorX * cellWidth,
});
}
setShowAutocomplete(true);
}
}
return false; // Prevent default Tab behavior
}
// Let terminal handle all other keys
return true;
};
terminal.attachCustomKeyEventHandler(handleCustomKey);
}, [terminal]);
useEffect(() => {
if (!terminal || !hostConfig || !visible) return;
@@ -1078,17 +1434,30 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor}
/>
{isConnecting && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ backgroundColor }}
>
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">{t("terminal.connecting")}</span>
</div>
</div>
)}
<CommandHistoryDialog
open={showHistoryDialog}
onOpenChange={setShowHistoryDialog}
commands={commandHistory}
onSelectCommand={handleSelectCommand}
onDeleteCommand={handleDeleteCommand}
isLoading={isLoadingHistory}
/>
<CommandAutocomplete
visible={showAutocomplete}
suggestions={autocompleteSuggestions}
selectedIndex={autocompleteSelectedIndex}
position={autocompletePosition}
onSelect={handleAutocompleteSelect}
/>
<LoadingOverlay
visible={isConnecting}
minDuration={800}
message={t("terminal.connecting")}
backgroundColor={backgroundColor}
showLogo={true}
/>
</div>
);
},

View File

@@ -6,17 +6,19 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Hammer, Wrench, FileText } from "lucide-react";
import { Hammer, Wrench, FileText, Command } from "lucide-react";
import { useTranslation } from "react-i18next";
interface ToolsMenuProps {
onOpenSshTools: () => void;
onOpenSnippets: () => void;
onOpenCommandPalette: () => void;
}
export function ToolsMenu({
onOpenSshTools,
onOpenSnippets,
onOpenCommandPalette,
}: ToolsMenuProps): React.ReactElement {
const { t } = useTranslation();
@@ -33,7 +35,7 @@ export function ToolsMenu({
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-56 bg-dark-bg border-dark-border text-white"
className="w-70 bg-dark-bg border-dark-border text-white"
>
<DropdownMenuItem
onClick={onOpenSshTools}
@@ -49,6 +51,18 @@ export function ToolsMenu({
<FileText className="h-4 w-4" />
<span className="flex-1">{t("snippets.title")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenCommandPalette}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Command className="h-4 w-4" />
<div className="flex items-center justify-between flex-1">
<span>Command Palette</span>
<kbd className="ml-2 px-1.5 py-0.5 text-xs font-semibold bg-dark-bg-darker border border-dark-border rounded">
LShift LShift
</kbd>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs.tsx";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx";
import { toast } from "sonner";
@@ -24,11 +25,20 @@ import {
verifyTOTPLogin,
getServerConfig,
isElectron,
logoutUser,
} from "../../main-axios.ts";
import { ElectronServerConfig as ServerConfigComponent } from "@/ui/desktop/authentication/ElectronServerConfig.tsx";
import { ElectronLoginForm } from "@/ui/desktop/authentication/ElectronLoginForm.tsx";
function getCookie(name: string): string | undefined {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(";").shift();
}
interface ExtendedWindow extends Window {
IS_ELECTRON_WEBVIEW?: boolean;
}
interface AuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void;
@@ -37,7 +47,6 @@ interface AuthProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
authLoading: boolean;
setDbError: (error: string | null) => void;
dbError?: string | null;
onAuthSuccess: (authData: {
isAdmin: boolean;
username: string | null;
@@ -54,21 +63,20 @@ export function Auth({
loggedIn,
authLoading,
setDbError,
dbError,
onAuthSuccess,
...props
}: AuthProps) {
const { t } = useTranslation();
const isInElectronWebView = () => {
if ((window as any).IS_ELECTRON_WEBVIEW) {
if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) {
return true;
}
try {
if (window.self !== window.top) {
return true;
}
} catch (e) {
} catch (_e) {
return false;
}
return false;
@@ -126,7 +134,7 @@ export function Auth({
userId: meRes.userId || null,
});
toast.success(t("messages.loginSuccess"));
} catch (err) {
} catch (_err) {
toast.error(t("errors.failedUserInfo"));
}
}, [
@@ -206,7 +214,7 @@ export function Auth({
.finally(() => {
setDbHealthChecking(false);
});
}, [setDbError, firstUserToastShown, showServerConfig]);
}, [setDbError, firstUserToastShown, showServerConfig, t]);
useEffect(() => {
if (!registrationAllowed && !internalLoggedIn) {
@@ -282,9 +290,9 @@ export function Auth({
);
setWebviewAuthSuccess(true);
setTimeout(() => window.location.reload(), 100);
setLoading(false);
return;
} catch (e) {}
} catch (e) {
console.error("Error posting auth success message:", e);
}
}
const [meRes] = await Promise.all([getUserInfo()]);
@@ -461,7 +469,9 @@ export function Auth({
setTimeout(() => window.location.reload(), 100);
setTotpLoading(false);
return;
} catch (e) {}
} catch (e) {
console.error("Error posting auth success message:", e);
}
}
setInternalLoggedIn(true);
@@ -569,7 +579,9 @@ export function Auth({
setTimeout(() => window.location.reload(), 100);
setOidcLoading(false);
return;
} catch (e) {}
} catch (e) {
console.error("Error posting auth success message:", e);
}
}
}
@@ -607,7 +619,16 @@ export function Auth({
setOidcLoading(false);
});
}
}, []);
}, [
onAuthSuccess,
setDbError,
setIsAdmin,
setLoggedIn,
setUserId,
setUsername,
t,
isInElectronWebView,
]);
const Spinner = (
<svg
@@ -663,7 +684,7 @@ export function Auth({
if (showServerConfig === null && !isInElectronWebView()) {
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 ${className || ""}`}
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 animate-in fade-in zoom-in-95 duration-300 ${className || ""}`}
style={{ maxHeight: "calc(100vh - 1rem)" }}
{...props}
>
@@ -677,7 +698,7 @@ export function Auth({
if (showServerConfig && !isInElectronWebView()) {
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 ${className || ""}`}
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 animate-in fade-in zoom-in-95 duration-300 ${className || ""}`}
style={{ maxHeight: "calc(100vh - 1rem)" }}
{...props}
>
@@ -702,7 +723,7 @@ export function Auth({
) {
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 ${className || ""}`}
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 animate-in fade-in zoom-in-95 duration-300 ${className || ""}`}
style={{ maxHeight: "calc(100vh - 1rem)" }}
{...props}
>
@@ -735,7 +756,7 @@ export function Auth({
if (dbHealthChecking && !dbConnectionFailed) {
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 ${className || ""}`}
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 animate-in fade-in zoom-in-95 duration-300 ${className || ""}`}
style={{ maxHeight: "calc(100vh - 1rem)" }}
{...props}
>
@@ -754,7 +775,7 @@ export function Auth({
if (dbConnectionFailed) {
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 ${className || ""}`}
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 animate-in fade-in zoom-in-95 duration-300 ${className || ""}`}
style={{ maxHeight: "calc(100vh - 1rem)" }}
{...props}
>
@@ -814,10 +835,50 @@ export function Auth({
return (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-dark-bg border-2 border-dark-border rounded-md overflow-y-auto my-2 ${className || ""}`}
style={{ maxHeight: "calc(100vh - 1rem)" }}
className={`fixed inset-0 flex items-center justify-center ${className || ""}`}
{...props}
>
{/* Split Screen Layout */}
<div className="w-full h-full flex flex-col md:flex-row">
{/* Left Side - Brand Showcase */}
<div
className="hidden md:flex md:w-2/5 items-center justify-center relative"
style={{
background: '#0e0e10',
backgroundImage: `repeating-linear-gradient(
45deg,
transparent,
transparent 35px,
rgba(255, 255, 255, 0.03) 35px,
rgba(255, 255, 255, 0.03) 37px
)`
}}
>
{/* Logo and Branding */}
<div className="relative text-center px-8">
<div
className="text-7xl font-bold tracking-wider mb-4 text-foreground"
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
}}
>
TERMIX
</div>
<div className="text-lg text-muted-foreground tracking-widest font-light">
{t("auth.tagline") || "SSH TERMINAL MANAGER"}
</div>
<div className="mt-8 text-sm text-muted-foreground/80 max-w-md">
{t("auth.description") || "Secure, powerful, and intuitive SSH connection management"}
</div>
</div>
</div>
{/* Right Side - Auth Form */}
<div className="flex-1 flex items-center justify-center p-6 md:p-12 bg-background overflow-y-auto">
<div className="w-full max-w-md backdrop-blur-sm bg-card/50 rounded-2xl p-8 shadow-xl border-2 border-dark-border animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ maxHeight: "calc(100vh - 3rem)" }}
>
{isInElectronWebView() && !webviewAuthSuccess && (
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
<Monitor className="h-4 w-4" />
@@ -827,21 +888,6 @@ export function Auth({
)}
{isInElectronWebView() && webviewAuthSuccess && (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center">
<svg
className="w-10 h-10 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div className="text-center">
<h2 className="text-xl font-bold mb-2">
{t("messages.loginSuccess")}
@@ -928,72 +974,49 @@ export function Auth({
return (
<>
<div className="flex gap-2 mb-6">
{passwordLoginAllowed && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
{t("common.login")}
</button>
)}
{(passwordLoginAllowed || firstUser) &&
registrationAllowed && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
{/* Tab Navigation */}
<Tabs value={tab} onValueChange={(value) => {
const newTab = value as "login" | "signup" | "external" | "reset";
setTab(newTab);
if (tab === "reset") resetPasswordState();
if ((tab === "login" && newTab === "signup") || (tab === "signup" && newTab === "login")) {
clearFormFields();
}
}} className="w-full mb-8">
<TabsList className="w-full">
{passwordLoginAllowed && (
<TabsTrigger
value="login"
disabled={loading || firstUser}
className="flex-1"
>
{t("common.login")}
</TabsTrigger>
)}
{(passwordLoginAllowed || firstUser) && registrationAllowed && (
<TabsTrigger
value="signup"
disabled={loading}
className="flex-1"
>
{t("common.register")}
</button>
</TabsTrigger>
)}
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup")
clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t("auth.external")}
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{oidcConfigured && (
<TabsTrigger
value="external"
disabled={oidcLoading}
className="flex-1"
>
{t("auth.external")}
</TabsTrigger>
)}
</TabsList>
</Tabs>
{/* Page Title */}
<div className="mb-8 text-center">
<h2 className="text-2xl font-bold">
{tab === "login"
? t("auth.loginTitle")
: tab === "signup"
@@ -1319,6 +1342,9 @@ export function Auth({
})()}
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -63,7 +63,9 @@ export function ElectronLoginForm({
}
}
}
} catch (err) {}
} catch (err) {
console.error("Authentication operation failed:", err);
}
};
window.addEventListener("message", handleMessage);
@@ -190,8 +192,12 @@ export function ElectronLoginForm({
);
}
}
} catch (err) {}
} catch (err) {}
} catch (err) {
console.error("Authentication operation failed:", err);
}
} catch (err) {
console.error("Authentication operation failed:", err);
}
};
const handleError = () => {

View File

@@ -37,7 +37,9 @@ export function ElectronServerConfig({
if (config?.serverUrl) {
setServerUrl(config.serverUrl);
}
} catch {}
} catch (error) {
console.error("Server config operation failed:", error);
}
};
const handleSaveConfig = async () => {

View File

@@ -34,8 +34,9 @@ import {
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
import { getSSHHosts } from "@/ui/main-axios.ts";
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { SSHFolder } from "@/types/index.ts";
interface SSHHost {
id: number;
@@ -65,6 +66,7 @@ interface SidebarProps {
isAdmin?: boolean;
username?: string | null;
children?: React.ReactNode;
onLogout?: () => void;
}
async function handleLogout() {
@@ -87,6 +89,7 @@ export function LeftSidebar({
isAdmin,
username,
children,
onLogout,
}: SidebarProps): React.ReactElement {
const { t } = useTranslation();
@@ -112,7 +115,11 @@ export function LeftSidebar({
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return;
if (isSplitScreenActive) return;
if (sshManagerTab) {
setCurrentTab(sshManagerTab.id);
return;
}
const id = addTab({ type: "ssh_manager", title: "Host Manager" });
setCurrentTab(id);
};
@@ -143,6 +150,20 @@ export function LeftSidebar({
const prevHostsRef = React.useRef<SSHHost[]>([]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(new Map());
const fetchFolderMetadata = React.useCallback(async () => {
try {
const folders = await getSSHFolders();
const metadataMap = new Map<string, SSHFolder>();
folders.forEach((folder) => {
metadataMap.set(folder.name, folder);
});
setFolderMetadata(metadataMap);
} catch (error) {
console.error("Failed to fetch folder metadata:", error);
}
}, []);
const fetchHosts = React.useCallback(async () => {
try {
@@ -208,13 +229,18 @@ export function LeftSidebar({
React.useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 300000);
fetchFolderMetadata();
const interval = setInterval(() => {
fetchHosts();
fetchFolderMetadata();
}, 300000);
return () => clearInterval(interval);
}, [fetchHosts]);
}, [fetchHosts, fetchFolderMetadata]);
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
fetchFolderMetadata();
};
const handleCredentialsChanged = () => {
fetchHosts();
@@ -237,7 +263,7 @@ export function LeftSidebar({
handleCredentialsChanged as EventListener,
);
};
}, [fetchHosts]);
}, [fetchHosts, fetchFolderMetadata]);
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
@@ -394,13 +420,11 @@ export function LeftSidebar({
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
variant="outline"
onClick={openSshManagerTab}
disabled={!!sshManagerTab || isSplitScreenActive}
disabled={isSplitScreenActive}
title={
sshManagerTab
? t("interface.sshManagerAlreadyOpen")
: isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
}
>
<HardDrive strokeWidth="2.5" />
@@ -435,15 +459,20 @@ export function LeftSidebar({
</div>
)}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
{sortedFolders.map((folder, idx) => {
const metadata = folderMetadata.get(folder);
return (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
folderColor={metadata?.color}
folderIcon={metadata?.icon}
/>
);
})}
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" />
@@ -486,7 +515,7 @@ export function LeftSidebar({
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}
onClick={onLogout || handleLogout}
>
<span>{t("common.logout")}</span>
</DropdownMenuItem>

View File

@@ -137,10 +137,10 @@ export function SSHAuthDialog({
return (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-dark-bg"
className="absolute inset-0 z-50 flex items-center justify-center bg-dark-bg animate-in fade-in duration-200"
style={{ backgroundColor }}
>
<Card className="w-full max-w-2xl mx-4 border-2">
<Card className="w-full max-w-2xl mx-4 border-2 animate-in fade-in zoom-in-95 duration-200">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />

View File

@@ -25,12 +25,12 @@ export function TOTPDialog({
if (!isOpen) return null;
return (
<div className="absolute inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 flex items-center justify-center z-50 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-dark-bg rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-primary" />
<h3 className="text-lg font-semibold">

View File

@@ -26,11 +26,13 @@ interface TabData {
interface TopNavbarProps {
isTopbarOpen: boolean;
setIsTopbarOpen: (open: boolean) => void;
onOpenCommandPalette: () => void;
}
export function TopNavbar({
isTopbarOpen,
setIsTopbarOpen,
onOpenCommandPalette,
}: TopNavbarProps): React.ReactElement {
const { state } = useSidebar();
const {
@@ -64,12 +66,14 @@ export function TopNavbar({
currentX: number;
startX: number;
targetIndex: number | null;
hoverTabIndex: number | null;
}>({
draggedId: null,
draggedIndex: null,
currentX: 0,
startX: 0,
targetIndex: null,
hoverTabIndex: null,
});
const containerRef = React.useRef<HTMLDivElement | null>(null);
const tabRefs = React.useRef<Map<number, HTMLDivElement>>(new Map());
@@ -121,6 +125,7 @@ export function TopNavbar({
startX: e.clientX,
currentX: e.clientX,
targetIndex: index,
hoverTabIndex: null,
});
};
@@ -205,6 +210,22 @@ export function TopNavbar({
return newTargetIndex;
};
const findHoveredTab = (clientX: number, clientY: number): number | null => {
for (const [index, tabEl] of tabRefs.current.entries()) {
if (!tabEl) continue;
const rect = tabEl.getBoundingClientRect();
if (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
) {
return index;
}
}
return null;
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
@@ -220,6 +241,14 @@ export function TopNavbar({
}));
}
const hoveredTabIndex = findHoveredTab(e.clientX, e.clientY);
if (hoveredTabIndex !== dragState.hoverTabIndex) {
setDragState((prev) => ({
...prev,
hoverTabIndex: hoveredTabIndex,
}));
}
const newTargetIndex = calculateTargetIndex();
if (newTargetIndex !== null && newTargetIndex !== dragState.targetIndex) {
setDragState((prev) => ({
@@ -238,7 +267,57 @@ export function TopNavbar({
const fromIndex = dragState.draggedIndex;
const toIndex = dragState.targetIndex;
const draggedId = dragState.draggedId;
const hoverTabIndex = dragState.hoverTabIndex;
// Check if dropping onto another tab for split screen
if (
fromIndex !== null &&
hoverTabIndex !== null &&
fromIndex !== hoverTabIndex &&
draggedId !== null
) {
const draggedTab = tabs[fromIndex];
const targetTab = tabs[hoverTabIndex];
const isDraggedSplittable =
draggedTab.type === "terminal" ||
draggedTab.type === "server" ||
draggedTab.type === "file_manager";
const isTargetSplittable =
targetTab.type === "terminal" ||
targetTab.type === "server" ||
targetTab.type === "file_manager";
// Both tabs must be splittable and target must not already be in split screen
if (
isDraggedSplittable &&
isTargetSplittable &&
!allSplitScreenTab.includes(targetTab.id) &&
allSplitScreenTab.length < 3
) {
// Trigger split screen for the dragged tab
setSplitScreenTab(draggedId);
setCurrentTab(targetTab.id);
setDragState({
draggedId: null,
draggedIndex: null,
startX: 0,
currentX: 0,
targetIndex: null,
hoverTabIndex: null,
});
setTimeout(() => {
isProcessingDropRef.current = false;
setIsInDropAnimation(false);
}, 50);
return;
}
}
// Original reorder logic
if (fromIndex !== null && toIndex !== null && fromIndex !== toIndex) {
prevTabsRef.current = tabs;
@@ -250,6 +329,7 @@ export function TopNavbar({
startX: 0,
currentX: 0,
targetIndex: null,
hoverTabIndex: null,
});
});
@@ -265,6 +345,7 @@ export function TopNavbar({
startX: 0,
currentX: 0,
targetIndex: null,
hoverTabIndex: null,
});
}
@@ -282,6 +363,7 @@ export function TopNavbar({
startX: 0,
currentX: 0,
targetIndex: null,
hoverTabIndex: null,
});
};
@@ -349,6 +431,25 @@ export function TopNavbar({
? dragState.currentX - dragState.startX
: 0;
// Check if this tab is a valid drop target for split screen
const draggedTab =
dragState.draggedIndex !== null
? tabs[dragState.draggedIndex]
: null;
const isDraggedSplittable =
draggedTab &&
(draggedTab.type === "terminal" ||
draggedTab.type === "server" ||
draggedTab.type === "file_manager");
const isValidDropTarget =
isDraggedSplittable &&
isSplittable &&
!isDraggingThisTab &&
!isSplit &&
allSplitScreenTab.length < 3;
const isHoveredDropTarget =
isValidDropTarget && dragState.hoverTabIndex === index;
let transform = "";
if (!isInDropAnimation) {
@@ -464,6 +565,8 @@ export function TopNavbar({
disableClose={disableClose}
isDragging={isDraggingThisTab}
isDragOver={false}
isValidDropTarget={isValidDropTarget}
isHoveredDropTarget={isHoveredDropTarget}
/>
</div>
);
@@ -476,6 +579,7 @@ export function TopNavbar({
<ToolsMenu
onOpenSshTools={() => setToolsSheetOpen(true)}
onOpenSnippets={() => setSnippetsSidebarOpen(true)}
onOpenCommandPalette={onOpenCommandPalette}
/>
<Button

View File

@@ -1,6 +1,18 @@
import React, { useState } from "react";
import { CardTitle } from "@/components/ui/card.tsx";
import { ChevronDown, Folder } from "lucide-react";
import {
ChevronDown,
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
} from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { Host } from "@/ui/desktop/navigation/hosts/Host.tsx";
import { Separator } from "@/components/ui/separator.tsx";
@@ -40,11 +52,15 @@ interface FolderCardProps {
hosts: SSHHost[];
isFirst: boolean;
isLast: boolean;
folderColor?: string;
folderIcon?: string;
}
export function FolderCard({
folderName,
hosts,
folderColor,
folderIcon,
}: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
@@ -52,6 +68,21 @@ export function FolderCard({
setIsExpanded(!isExpanded);
};
const iconMap: Record<string, React.ComponentType<{ size?: number; strokeWidth?: number; className?: string; style?: React.CSSProperties }>> = {
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
};
const FolderIcon = folderIcon && iconMap[folderIcon] ? iconMap[folderIcon] : Folder;
return (
<div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0">
<div
@@ -59,7 +90,11 @@ export function FolderCard({
>
<div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center">
<Folder size={16} strokeWidth={3} />
<FolderIcon
size={16}
strokeWidth={3}
style={folderColor ? { color: folderColor } : undefined}
/>
</div>
<div className="flex-1 min-w-0">
<CardTitle className="mb-0 leading-tight break-words text-md">

View File

@@ -27,6 +27,8 @@ interface TabProps {
disableClose?: boolean;
isDragging?: boolean;
isDragOver?: boolean;
isValidDropTarget?: boolean;
isHoveredDropTarget?: boolean;
}
export function Tab({
@@ -44,6 +46,8 @@ export function Tab({
disableClose = false,
isDragging = false,
isDragOver = false,
isValidDropTarget = false,
isHoveredDropTarget = false,
}: TabProps): React.ReactElement {
const { t } = useTranslation();
@@ -54,12 +58,21 @@ export function Tab({
isDragOver &&
"bg-background/40 text-muted-foreground border-border opacity-60",
isDragging && "opacity-70",
isHoveredDropTarget &&
"bg-blue-500/20 border-blue-500 ring-2 ring-blue-500/50",
!isHoveredDropTarget &&
isValidDropTarget &&
"border-blue-400/50 bg-background/90",
!isDragOver &&
!isDragging &&
!isValidDropTarget &&
!isHoveredDropTarget &&
isActive &&
"bg-background text-foreground border-border z-10",
!isDragOver &&
!isDragging &&
!isValidDropTarget &&
!isHoveredDropTarget &&
!isActive &&
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
);

View File

@@ -10,6 +10,7 @@ import {
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { User, Shield, AlertCircle } from "lucide-react";
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
import {
@@ -54,7 +55,9 @@ async function handleLogout() {
},
serverOrigin,
);
} catch (err) {}
} catch (err) {
console.error("User profile operation failed:", err);
}
}
}
}
@@ -85,6 +88,9 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
const [deletePassword, setDeletePassword] = useState("");
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [fileColorCoding, setFileColorCoding] = useState<boolean>(
localStorage.getItem("fileColorCoding") !== "false"
);
useEffect(() => {
fetchUserInfo();
@@ -126,6 +132,13 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
}
};
const handleFileColorCodingToggle = (enabled: boolean) => {
setFileColorCoding(enabled);
localStorage.setItem("fileColorCoding", enabled.toString());
// Trigger a re-render by dispatching a custom event
window.dispatchEvent(new Event("fileColorCodingChanged"));
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
@@ -323,6 +336,23 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.fileColorCoding")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.fileColorCodingDesc")}
</p>
</div>
<Switch
checked={fileColorCoding}
onCheckedChange={handleFileColorCodingToggle}
/>
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>

View File

@@ -0,0 +1,142 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
getCommandHistory,
saveCommandToHistory,
} from "@/ui/main-axios.ts";
interface UseCommandHistoryOptions {
hostId?: number;
enabled?: boolean;
}
interface CommandHistoryResult {
suggestions: string[];
getSuggestions: (input: string) => string[];
saveCommand: (command: string) => Promise<void>;
clearSuggestions: () => void;
isLoading: boolean;
}
/**
* Custom hook for managing command history and autocomplete suggestions
*/
export function useCommandHistory({
hostId,
enabled = true,
}: UseCommandHistoryOptions): CommandHistoryResult {
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const historyCache = useRef<Map<number, string[]>>(new Map());
// Fetch command history when hostId changes
useEffect(() => {
if (!enabled || !hostId) {
setCommandHistory([]);
setSuggestions([]);
return;
}
// Check cache first
const cached = historyCache.current.get(hostId);
if (cached) {
setCommandHistory(cached);
return;
}
// Fetch from server
const fetchHistory = async () => {
setIsLoading(true);
try {
const history = await getCommandHistory(hostId);
setCommandHistory(history);
historyCache.current.set(hostId, history);
} catch (error) {
console.error("Failed to fetch command history:", error);
setCommandHistory([]);
} finally {
setIsLoading(false);
}
};
fetchHistory();
}, [hostId, enabled]);
/**
* Get command suggestions based on current input
*/
const getSuggestions = useCallback(
(input: string): string[] => {
if (!input || input.trim().length === 0) {
return [];
}
const trimmedInput = input.trim();
const matches = commandHistory.filter((cmd) =>
cmd.startsWith(trimmedInput)
);
// Return up to 10 suggestions, excluding exact matches
const filtered = matches.filter((cmd) => cmd !== trimmedInput).slice(0, 10);
setSuggestions(filtered);
return filtered;
},
[commandHistory]
);
/**
* Save a command to history
*/
const saveCommand = useCallback(
async (command: string) => {
if (!enabled || !hostId || !command || command.trim().length === 0) {
return;
}
const trimmedCommand = command.trim();
// Skip if it's the same as the last command
if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) {
return;
}
try {
// Save to server
await saveCommandToHistory(hostId, trimmedCommand);
// Update local state - add to beginning
setCommandHistory((prev) => {
const newHistory = [trimmedCommand, ...prev.filter((c) => c !== trimmedCommand)];
// Keep max 500 commands in memory
const limited = newHistory.slice(0, 500);
historyCache.current.set(hostId, limited);
return limited;
});
} catch (error) {
console.error("Failed to save command to history:", error);
// Still update local state even if server save fails
setCommandHistory((prev) => {
const newHistory = [trimmedCommand, ...prev.filter((c) => c !== trimmedCommand)];
return newHistory.slice(0, 500);
});
}
},
[enabled, hostId, commandHistory]
);
/**
* Clear current suggestions
*/
const clearSuggestions = useCallback(() => {
setSuggestions([]);
}, []);
return {
suggestions,
getSuggestions,
saveCommand,
clearSuggestions,
isLoading,
};
}

View File

@@ -0,0 +1,144 @@
import { useRef, useCallback } from "react";
import { saveCommandToHistory } from "@/ui/main-axios.ts";
interface UseCommandTrackerOptions {
hostId?: number;
enabled?: boolean;
onCommandExecuted?: (command: string) => void;
}
interface CommandTrackerResult {
trackInput: (data: string) => void;
getCurrentCommand: () => string;
clearCurrentCommand: () => 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({
hostId,
enabled = true,
onCommandExecuted,
}: UseCommandTrackerOptions): CommandTrackerResult {
const currentCommandRef = useRef<string>("");
const isInEscapeSequenceRef = useRef<boolean>(false);
/**
* Track input data and detect command execution
*/
const trackInput = useCallback(
(data: string) => {
if (!enabled || !hostId) {
return;
}
// Handle each character
for (let i = 0; i < data.length; i++) {
const char = data[i];
const charCode = char.charCodeAt(0);
// Detect escape sequences (e.g., arrow keys, function keys)
if (charCode === 27) {
// ESC
isInEscapeSequenceRef.current = true;
continue;
}
// Skip characters that are part of escape sequences
if (isInEscapeSequenceRef.current) {
// Common escape sequence endings
if (
(charCode >= 65 && charCode <= 90) || // A-Z
(charCode >= 97 && charCode <= 122) || // a-z
charCode === 126 // ~
) {
isInEscapeSequenceRef.current = false;
}
continue;
}
// Handle Enter key (CR or LF)
if (charCode === 13 || charCode === 10) {
// \r or \n
const command = currentCommandRef.current.trim();
// Save non-empty commands
if (command.length > 0) {
// Save to history (async, don't wait)
saveCommandToHistory(hostId, command).catch((error) => {
console.error("Failed to save command to history:", error);
});
// Callback for external handling
if (onCommandExecuted) {
onCommandExecuted(command);
}
}
// Clear current command
currentCommandRef.current = "";
continue;
}
// Handle Backspace/Delete
if (charCode === 8 || charCode === 127) {
// Backspace or DEL
if (currentCommandRef.current.length > 0) {
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
}
continue;
}
// Handle Ctrl+C, Ctrl+D, etc. - clear current command
if (charCode === 3 || charCode === 4) {
currentCommandRef.current = "";
continue;
}
// Handle Ctrl+U (clear line) - common in terminals
if (charCode === 21) {
currentCommandRef.current = "";
continue;
}
// Add printable characters to current command
if (charCode >= 32 && charCode <= 126) {
// Printable ASCII
currentCommandRef.current += char;
}
}
},
[enabled, hostId, onCommandExecuted]
);
/**
* Get the current command being typed
*/
const getCurrentCommand = useCallback(() => {
return currentCommandRef.current;
}, []);
/**
* Clear the current command buffer
*/
const clearCurrentCommand = useCallback(() => {
currentCommandRef.current = "";
}, []);
/**
* Update the current command buffer (used for autocomplete)
*/
const updateCurrentCommand = useCallback((command: string) => {
currentCommandRef.current = command;
}, []);
return {
trackInput,
getCurrentCommand,
clearCurrentCommand,
updateCurrentCommand,
};
}

View File

@@ -48,7 +48,9 @@ export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) {
store.put({ handle: dirHandle }, "lastSaveDir");
};
}
} catch {}
} catch (error) {
console.error("Drag operation failed:", error);
}
};
const isFileSystemAPISupported = () => {

View File

@@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from "axios";
import type {
SSHHost,
SSHHostData,
SSHFolder,
TunnelConfig,
TunnelStatus,
FileManagerFile,
@@ -1515,6 +1516,145 @@ export async function moveSSHItem(
}
}
export async function changeSSHPermissions(
sessionId: string,
path: string,
permissions: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string }> {
try {
fileLogger.info("Changing SSH file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/changePermissions", {
sessionId,
path,
permissions,
hostId,
userId,
});
fileLogger.success("SSH file permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to change SSH file permissions", error, {
operation: "change_permissions",
sessionId,
path,
permissions,
});
handleApiError(error, "change SSH permissions");
throw error;
}
}
export async function extractSSHArchive(
sessionId: string,
archivePath: string,
extractPath?: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string; extractPath: string }> {
try {
fileLogger.info("Extracting archive", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/extractArchive", {
sessionId,
archivePath,
extractPath,
hostId,
userId,
});
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: response.data.extractPath,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to extract archive", error, {
operation: "extract_archive",
sessionId,
archivePath,
extractPath,
});
handleApiError(error, "extract archive");
throw error;
}
}
export async function compressSSHFiles(
sessionId: string,
paths: string[],
archiveName: string,
format?: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string; archivePath: string }> {
try {
fileLogger.info("Compressing files", {
operation: "compress_files",
sessionId,
paths,
archiveName,
format,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/compressFiles", {
sessionId,
paths,
archiveName,
format: format || "zip",
hostId,
userId,
});
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
sessionId,
paths,
archivePath: response.data.archivePath,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to compress files", error, {
operation: "compress_files",
sessionId,
paths,
archiveName,
format,
});
handleApiError(error, "compress files");
throw error;
}
}
// ============================================================================
// FILE MANAGER DATA
// ============================================================================
@@ -2406,6 +2546,90 @@ export async function renameFolder(
}
}
export async function getSSHFolders(): Promise<SSHFolder[]> {
try {
sshLogger.info("Fetching SSH folders", {
operation: "fetch_ssh_folders",
});
const response = await authApi.get("/ssh/folders");
sshLogger.success("SSH folders fetched successfully", {
operation: "fetch_ssh_folders",
count: response.data.length,
});
return response.data;
} catch (error) {
sshLogger.error("Failed to fetch SSH folders", error, {
operation: "fetch_ssh_folders",
});
handleApiError(error, "fetch SSH folders");
throw error;
}
}
export async function updateFolderMetadata(
name: string,
color?: string,
icon?: string,
): Promise<void> {
try {
sshLogger.info("Updating folder metadata", {
operation: "update_folder_metadata",
name,
color,
icon,
});
await authApi.put("/ssh/folders/metadata", {
name,
color,
icon,
});
sshLogger.success("Folder metadata updated successfully", {
operation: "update_folder_metadata",
name,
});
} catch (error) {
sshLogger.error("Failed to update folder metadata", error, {
operation: "update_folder_metadata",
name,
});
handleApiError(error, "update folder metadata");
throw error;
}
}
export async function deleteAllHostsInFolder(
folderName: string,
): Promise<{ deletedCount: number }> {
try {
sshLogger.info("Deleting all hosts in folder", {
operation: "delete_folder_hosts",
folderName,
});
const response = await authApi.delete(`/ssh/folders/${encodeURIComponent(folderName)}/hosts`);
sshLogger.success("All hosts in folder deleted successfully", {
operation: "delete_folder_hosts",
folderName,
deletedCount: response.data.deletedCount,
});
return response.data;
} catch (error) {
sshLogger.error("Failed to delete hosts in folder", error, {
operation: "delete_folder_hosts",
folderName,
});
handleApiError(error, "delete hosts in folder");
throw error;
}
}
export async function renameCredentialFolder(
oldName: string,
newName: string,
@@ -2626,3 +2850,72 @@ export async function resetRecentActivity(): Promise<{ message: string }> {
throw handleApiError(error, "reset recent activity");
}
}
// ============================================================================
// COMMAND HISTORY API
// ============================================================================
/**
* Save a command to history for a specific host
*/
export async function saveCommandToHistory(
hostId: number,
command: string,
): Promise<{ id: number; command: string; executedAt: string }> {
try {
const response = await authApi.post("/terminal/command_history", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save command to history");
}
}
/**
* Get command history for a specific host
* Returns array of unique commands ordered by most recent
*/
export async function getCommandHistory(
hostId: number,
): Promise<string[]> {
try {
const response = await authApi.get(`/terminal/command_history/${hostId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch command history");
}
}
/**
* Delete a specific command from history
*/
export async function deleteCommandFromHistory(
hostId: number,
command: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/terminal/command_history/delete", {
hostId,
command,
});
return response.data;
} catch (error) {
throw handleApiError(error, "delete command from history");
}
}
/**
* Clear command history for a specific host (optional feature)
*/
export async function clearCommandHistory(
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/terminal/command_history/${hostId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "clear command history");
}
}

View File

@@ -101,7 +101,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal as { refresh?: (start: number, end: number) => void }
).refresh(0, terminal.rows - 1);
}
} catch {}
} catch (error) {
console.error("Terminal operation failed:", error);
}
}
function performFit() {
@@ -175,7 +177,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
scheduleNotify(cols, rows);
hardRefresh();
}
} catch {}
} catch (error) {
console.error("Terminal operation failed:", error);
}
},
refresh: () => hardRefresh(),
}),
@@ -225,7 +229,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
`\r\n[${msg.message || t("terminal.disconnected")}]`,
);
}
} catch {}
} catch (error) {
console.error("Terminal operation failed:", error);
}
});
ws.addEventListener("close", (event) => {

View File

@@ -110,7 +110,9 @@ export function TerminalKeyboard({
if (navigator.vibrate) {
navigator.vibrate(20);
}
} catch {}
} catch (error) {
console.error("Keyboard operation failed:", error);
}
onSendInput(input);
},

View File

@@ -52,7 +52,9 @@ function postJWTToWebView() {
timestamp: Date.now(),
}),
);
} catch (error) {}
} catch (error) {
console.error("Auth operation failed:", error);
}
}
interface AuthProps extends React.ComponentProps<"div"> {

View File

@@ -8,6 +8,7 @@
"moduleResolution": "nodenext",
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"esModuleInterop": true,
"noEmit": false,
"outDir": "./dist/backend",
"strict": false,