v1.9.0 #437
@@ -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(
|
||||
(
|
||||
|
||||
@@ -300,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,
|
||||
@@ -311,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 {
|
||||
|
||||
@@ -212,6 +212,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")
|
||||
@@ -226,3 +242,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`),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
recentActivity,
|
||||
sshFolders,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -226,7 +226,6 @@ router.post(
|
||||
authMethod,
|
||||
authType,
|
||||
credentialId,
|
||||
overrideCredentialUsername,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -266,7 +265,6 @@ router.post(
|
||||
username,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
enableTunnel: enableTunnel ? 1 : 0,
|
||||
@@ -326,7 +324,6 @@ router.post(
|
||||
: []
|
||||
: [],
|
||||
pin: !!createdHost.pin,
|
||||
overrideCredentialUsername: !!createdHost.overrideCredentialUsername,
|
||||
enableTerminal: !!createdHost.enableTerminal,
|
||||
enableTunnel: !!createdHost.enableTunnel,
|
||||
tunnelConnections: createdHost.tunnelConnections
|
||||
@@ -353,27 +350,6 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
|
||||
await fetch("http://localhost:30005/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Cookie: `jwt=${token}` }),
|
||||
},
|
||||
});
|
||||
} catch (refreshError) {
|
||||
sshLogger.warn("Failed to refresh server stats polling", {
|
||||
operation: "stats_refresh_after_create",
|
||||
error:
|
||||
refreshError instanceof Error
|
||||
? refreshError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to save SSH host to database", err, {
|
||||
@@ -440,7 +416,6 @@ router.put(
|
||||
authMethod,
|
||||
authType,
|
||||
credentialId,
|
||||
overrideCredentialUsername,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -481,7 +456,6 @@ router.put(
|
||||
username,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
enableTunnel: enableTunnel ? 1 : 0,
|
||||
@@ -559,7 +533,6 @@ router.put(
|
||||
: []
|
||||
: [],
|
||||
pin: !!updatedHost.pin,
|
||||
overrideCredentialUsername: !!updatedHost.overrideCredentialUsername,
|
||||
enableTerminal: !!updatedHost.enableTerminal,
|
||||
enableTunnel: !!updatedHost.enableTunnel,
|
||||
tunnelConnections: updatedHost.tunnelConnections
|
||||
@@ -586,27 +559,6 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
|
||||
await fetch("http://localhost:30005/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Cookie: `jwt=${token}` }),
|
||||
},
|
||||
});
|
||||
} catch (refreshError) {
|
||||
sshLogger.warn("Failed to refresh server stats polling", {
|
||||
operation: "stats_refresh_after_update",
|
||||
error:
|
||||
refreshError instanceof Error
|
||||
? refreshError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
res.json(resolvedHost);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update SSH host in database", err, {
|
||||
@@ -634,18 +586,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
sshLogger.warn("User data not unlocked for SSH host fetch", {
|
||||
operation: "host_fetch",
|
||||
userId,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await SimpleDBOps.select(
|
||||
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||
@@ -664,7 +604,6 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
||||
: []
|
||||
: [],
|
||||
pin: !!row.pin,
|
||||
overrideCredentialUsername: !!row.overrideCredentialUsername,
|
||||
enableTerminal: !!row.enableTerminal,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections
|
||||
@@ -711,19 +650,6 @@ router.get(
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
sshLogger.warn("User data not unlocked for SSH host fetch by ID", {
|
||||
operation: "host_fetch_by_id",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await db
|
||||
.select()
|
||||
@@ -749,7 +675,6 @@ router.get(
|
||||
: []
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
overrideCredentialUsername: !!host.overrideCredentialUsername,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
@@ -924,15 +849,6 @@ router.delete(
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(
|
||||
and(
|
||||
eq(recentActivity.userId, userId),
|
||||
eq(recentActivity.hostId, numericHostId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(sshData)
|
||||
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
|
||||
@@ -1352,9 +1268,7 @@ async function resolveHostCredentials(
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
username: host.overrideCredentialUsername
|
||||
? host.username
|
||||
: credential.username,
|
||||
username: credential.username,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
password: credential.password,
|
||||
key: credential.key,
|
||||
@@ -1428,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,
|
||||
@@ -1445,6 +1370,151 @@ 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(
|
||||
@@ -1533,10 +1603,8 @@ router.post(
|
||||
username: hostData.username,
|
||||
password: hostData.authType === "password" ? hostData.password : null,
|
||||
authType: hostData.authType,
|
||||
credentialId: hostData.credentialId || null,
|
||||
overrideCredentialUsername: hostData.overrideCredentialUsername
|
||||
? 1
|
||||
: 0,
|
||||
credentialId:
|
||||
hostData.authType === "credential" ? hostData.credentialId : null,
|
||||
key: hostData.authType === "key" ? hostData.key : null,
|
||||
keyPassword:
|
||||
hostData.authType === "key"
|
||||
|
||||
214
src/backend/database/routes/terminal.ts
Normal file
214
src/backend/database/routes/terminal.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
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;
|
||||
@@ -2490,6 +2490,474 @@ 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);
|
||||
|
||||
@@ -111,6 +111,7 @@ class SystemCrypto {
|
||||
} else {
|
||||
}
|
||||
} 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",
|
||||
{
|
||||
|
||||
@@ -755,6 +755,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",
|
||||
@@ -895,6 +906,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",
|
||||
@@ -1172,7 +1199,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",
|
||||
@@ -1482,6 +1521,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."
|
||||
|
||||
@@ -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": "统计",
|
||||
@@ -904,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": "上一页",
|
||||
@@ -1151,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 隧道",
|
||||
@@ -1442,6 +1481,8 @@
|
||||
"local": "本地",
|
||||
"external": "外部 (OIDC)",
|
||||
"selectPreferredLanguage": "选择您的界面首选语言",
|
||||
"fileColorCoding": "文件颜色编码",
|
||||
"fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)",
|
||||
"currentPassword": "当前密码",
|
||||
"passwordChangedSuccess": "密码修改成功!请重新登录。",
|
||||
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
|
||||
|
||||
@@ -64,6 +64,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
|
||||
// ============================================================================
|
||||
|
||||
1739
src/ui/components/LoadingOverlay.tsx
Normal file
1739
src/ui/components/LoadingOverlay.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,11 @@ 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();
|
||||
|
||||
@@ -1037,6 +1047,82 @@ 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"));
|
||||
@@ -1159,6 +1245,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;
|
||||
|
||||
@@ -1947,10 +2061,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}
|
||||
@@ -1972,6 +2096,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PermissionsDialog
|
||||
file={permissionsDialogFile}
|
||||
open={permissionsDialogFile !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPermissionsDialogFile(null);
|
||||
}}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Play,
|
||||
Star,
|
||||
Bookmark,
|
||||
FileArchive,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
@@ -60,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 {
|
||||
@@ -99,6 +102,8 @@ export function FileManagerContextMenu({
|
||||
onAddShortcut,
|
||||
isPinned,
|
||||
currentPath,
|
||||
onExtractArchive,
|
||||
onCompress,
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [menuPosition, setMenuPosition] = useState({ x, y });
|
||||
@@ -254,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;
|
||||
|
||||
@@ -451,7 +495,7 @@ export function FileManagerContextMenu({
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[99990] transition-opacity duration-150",
|
||||
!isMounted && "opacity-0"
|
||||
!isMounted && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -460,7 +504,7 @@ export function FileManagerContextMenu({
|
||||
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"
|
||||
isMounted ? "opacity-100 scale-100" : "opacity-0 scale-95",
|
||||
)}
|
||||
style={{
|
||||
left: menuPosition.x,
|
||||
|
||||
@@ -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;
|
||||
@@ -96,15 +97,33 @@ interface FileManagerGridProps {
|
||||
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();
|
||||
@@ -113,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":
|
||||
@@ -150,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":
|
||||
@@ -159,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}`} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -853,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
|
||||
@@ -1051,7 +1059,8 @@ export function FileManagerGrid({
|
||||
"group p-3 rounded-lg cursor-pointer",
|
||||
"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",
|
||||
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) &&
|
||||
@@ -1312,6 +1321,13 @@ export function FileManagerGrid({
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<LoadingOverlay
|
||||
visible={isLoading}
|
||||
minDuration={600}
|
||||
message={t("common.loading")}
|
||||
showLogo={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
150
src/ui/desktop/apps/file-manager/components/CompressDialog.tsx
Normal file
150
src/ui/desktop/apps/file-manager/components/CompressDialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,24 @@ 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 +147,159 @@ 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 +1038,18 @@ 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 +1130,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 +1196,32 @@ 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 +1444,88 @@ 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 +1549,26 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
194
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
194
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type StatsConfig,
|
||||
DEFAULT_STATS_CONFIG,
|
||||
} from "@/types/stats-widgets";
|
||||
import { LoadingOverlay } from "@/ui/components/LoadingOverlay";
|
||||
import {
|
||||
CpuWidget,
|
||||
MemoryWidget,
|
||||
@@ -443,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">
|
||||
@@ -476,6 +468,13 @@ export function Server({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoadingOverlay
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
minDuration={700}
|
||||
message={t("serverStats.loadingMetrics")}
|
||||
showLogo={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
67
src/ui/desktop/apps/terminal/CommandAutocomplete.tsx
Normal file
67
src/ui/desktop/apps/terminal/CommandAutocomplete.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx
Normal file
238
src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -112,7 +118,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
|
||||
useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isReadyRef = useRef<boolean>(false);
|
||||
const isFittingRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
@@ -123,6 +128,104 @@ 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);
|
||||
@@ -158,10 +261,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
isReadyRef.current = isReady;
|
||||
}, [isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
@@ -516,9 +615,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}),
|
||||
);
|
||||
terminal.onData((data) => {
|
||||
if (data === "\x00" || data === "\u0000") {
|
||||
return;
|
||||
}
|
||||
// Track command input for history (Stage 1)
|
||||
trackInput(data);
|
||||
// Send input to server
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
@@ -778,6 +877,88 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle command selection from history dialog (Stage 2)
|
||||
const handleSelectCommand = useCallback(
|
||||
(command: string) => {
|
||||
if (!terminal || !webSocketRef.current) return;
|
||||
|
||||
// Send the command to the terminal
|
||||
// Simulate typing the command character by character
|
||||
for (const char of command) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: char }),
|
||||
);
|
||||
}
|
||||
|
||||
// Return focus to terminal after selecting command
|
||||
setTimeout(() => {
|
||||
terminal.focus();
|
||||
}, 100);
|
||||
},
|
||||
[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;
|
||||
|
||||
@@ -882,6 +1063,20 @@ 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" &&
|
||||
@@ -933,21 +1128,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
element?.addEventListener("keydown", handleMacKeyboard, true);
|
||||
|
||||
const handleResize = () => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
if (!isVisibleRef.current || !isReadyRef.current) return;
|
||||
if (!isVisibleRef.current || !isReady) return;
|
||||
performFit();
|
||||
}, 100);
|
||||
};
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
|
||||
if (xtermRef.current) {
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
setVisible(true);
|
||||
|
||||
@@ -960,7 +1149,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setIsReady(false);
|
||||
isFittingRef.current = false;
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", handleResize);
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
@@ -977,6 +1165,192 @@ 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;
|
||||
|
||||
@@ -1103,17 +1477,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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,7 +5,12 @@ 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 {
|
||||
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";
|
||||
@@ -559,10 +564,6 @@ export function Auth({
|
||||
if (success) {
|
||||
setOidcLoading(true);
|
||||
|
||||
// Clear the success parameter first to prevent re-processing
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
setTimeout(() => {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
if (isInElectronWebView()) {
|
||||
@@ -601,19 +602,27 @@ export function Auth({
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get user info after OIDC callback:", err);
|
||||
.catch(() => {
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcLoading(false);
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
}, [
|
||||
onAuthSuccess,
|
||||
@@ -836,19 +845,18 @@ export function Auth({
|
||||
>
|
||||
{/* 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',
|
||||
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 */}
|
||||
@@ -856,7 +864,8 @@ export function Auth({
|
||||
<div
|
||||
className="text-7xl font-bold tracking-wider mb-4 text-foreground"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
}}
|
||||
>
|
||||
TERMIX
|
||||
@@ -865,21 +874,25 @@ export function Auth({
|
||||
{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"}
|
||||
{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"
|
||||
<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" />
|
||||
<AlertTitle>{t("auth.desktopApp")}</AlertTitle>
|
||||
<AlertDescription>{t("auth.loggingInToDesktopApp")}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{t("auth.loggingInToDesktopApp")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isInElectronWebView() && webviewAuthSuccess && (
|
||||
@@ -911,7 +924,9 @@ export function Auth({
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
|
||||
onChange={(e) =>
|
||||
setTotpCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
disabled={totpLoading}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
@@ -946,12 +961,16 @@ export function Auth({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!webviewAuthSuccess && !loggedIn && !authLoading && !totpRequired && (
|
||||
{!webviewAuthSuccess &&
|
||||
!loggedIn &&
|
||||
!authLoading &&
|
||||
!totpRequired && (
|
||||
<>
|
||||
{(() => {
|
||||
const hasLogin = passwordLoginAllowed && !firstUser;
|
||||
const hasSignup =
|
||||
(passwordLoginAllowed || firstUser) && registrationAllowed;
|
||||
(passwordLoginAllowed || firstUser) &&
|
||||
registrationAllowed;
|
||||
const hasOIDC = oidcConfigured;
|
||||
const hasAnyAuth = hasLogin || hasSignup || hasOIDC;
|
||||
|
||||
@@ -971,14 +990,25 @@ export function Auth({
|
||||
return (
|
||||
<>
|
||||
{/* Tab Navigation */}
|
||||
<Tabs value={tab} onValueChange={(value) => {
|
||||
const newTab = value as "login" | "signup" | "external" | "reset";
|
||||
<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")) {
|
||||
if (
|
||||
(tab === "login" && newTab === "signup") ||
|
||||
(tab === "signup" && newTab === "login")
|
||||
) {
|
||||
clearFormFields();
|
||||
}
|
||||
}} className="w-full mb-8">
|
||||
}}
|
||||
className="w-full mb-8"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{passwordLoginAllowed && (
|
||||
<TabsTrigger
|
||||
@@ -989,7 +1019,8 @@ export function Auth({
|
||||
{t("common.login")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{(passwordLoginAllowed || firstUser) && registrationAllowed && (
|
||||
{(passwordLoginAllowed || firstUser) &&
|
||||
registrationAllowed && (
|
||||
<TabsTrigger
|
||||
value="signup"
|
||||
disabled={loading}
|
||||
@@ -1035,7 +1066,9 @@ export function Auth({
|
||||
return (
|
||||
<div className="text-center p-4 bg-muted/50 rounded-lg border">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.externalNotSupportedInElectron")}
|
||||
{t(
|
||||
"auth.externalNotSupportedInElectron",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -1060,8 +1093,13 @@ export function Auth({
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>{t("common.warning")}</AlertTitle>
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="mb-4"
|
||||
>
|
||||
<AlertTitle>
|
||||
{t("common.warning")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.dataLossWarning")}
|
||||
</AlertDescription>
|
||||
@@ -1089,7 +1127,9 @@ export function Auth({
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
disabled={
|
||||
resetLoading || !localUsername.trim()
|
||||
}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
@@ -1157,7 +1197,8 @@ export function Auth({
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
{resetStep === "newPassword" &&
|
||||
!resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
@@ -1232,21 +1273,30 @@ export function Auth({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<form
|
||||
className="flex flex-col gap-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Label htmlFor="username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setLocalUsername(e.target.value)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<Label htmlFor="password">
|
||||
{t("common.password")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
|
||||
@@ -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;
|
||||
@@ -114,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);
|
||||
};
|
||||
@@ -145,6 +150,22 @@ 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 {
|
||||
@@ -210,13 +231,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();
|
||||
@@ -239,7 +265,7 @@ export function LeftSidebar({
|
||||
handleCredentialsChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
}, [fetchHosts, fetchFolderMetadata]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
@@ -396,11 +422,9 @@ 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
|
||||
isSplitScreenActive
|
||||
? t("interface.disabledDuringSplitScreen")
|
||||
: undefined
|
||||
}
|
||||
@@ -437,15 +461,20 @@ export function LeftSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
{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" />
|
||||
|
||||
@@ -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,30 @@ 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 +99,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">
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
@@ -93,6 +94,9 @@ export function UserProfile({
|
||||
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();
|
||||
@@ -134,6 +138,13 @@ export function UserProfile({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileColorCodingToggle = (enabled: boolean) => {
|
||||
setFileColorCoding(enabled);
|
||||
localStorage.setItem("fileColorCoding", enabled.toString());
|
||||
// Trigger a re-render by dispatching a custom event
|
||||
window.dispatchEvent(new Event("fileColorCodingChanged"));
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
@@ -331,6 +342,23 @@ export function UserProfile({
|
||||
</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>
|
||||
|
||||
147
src/ui/hooks/useCommandHistory.ts
Normal file
147
src/ui/hooks/useCommandHistory.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
144
src/ui/hooks/useCommandTracker.ts
Normal file
144
src/ui/hooks/useCommandTracker.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from "axios";
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHHostData,
|
||||
SSHFolder,
|
||||
TunnelConfig,
|
||||
TunnelStatus,
|
||||
FileManagerFile,
|
||||
@@ -1520,6 +1521,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
|
||||
// ============================================================================
|
||||
@@ -2411,6 +2551,92 @@ 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,
|
||||
@@ -2631,3 +2857,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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user