feat: Improve dashboard API, improve tab system, various other fixes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ dist-ssr
|
|||||||
/.claude/
|
/.claude/
|
||||||
/ssl/
|
/ssl/
|
||||||
.env
|
.env
|
||||||
|
/.mcp.json
|
||||||
|
|||||||
@@ -255,6 +255,17 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
host_name TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||||
|
);
|
||||||
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
migrateSchema();
|
migrateSchema();
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
hostName,
|
hostName,
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
)) as { id: number };
|
)) as unknown as { id: number };
|
||||||
|
|
||||||
// Keep only the last 100 activities per user to prevent bloat
|
// Keep only the last 100 activities per user to prevent bloat
|
||||||
const allActivities = await SimpleDBOps.select(
|
const allActivities = await SimpleDBOps.select(
|
||||||
@@ -253,10 +253,6 @@ const PORT = 30006;
|
|||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
try {
|
try {
|
||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
homepageLogger.success(`Homepage API listening on port ${PORT}`, {
|
|
||||||
operation: "server_start",
|
|
||||||
port: PORT,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
homepageLogger.error("Failed to initialize AuthManager", err, {
|
homepageLogger.error("Failed to initialize AuthManager", err, {
|
||||||
operation: "auth_init_error",
|
operation: "auth_init_error",
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ interface PendingTOTPSession {
|
|||||||
port?: number;
|
port?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||||
|
totpPromptIndex?: number;
|
||||||
|
resolvedPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sshSessions: Record<string, SSHSession> = {};
|
const sshSessions: Record<string, SSHSession> = {};
|
||||||
@@ -459,30 +462,28 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
promptsCount: prompts.length,
|
promptsCount: prompts.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totpPrompt = prompts.find((p) =>
|
const totpPromptIndex = prompts.findIndex((p) =>
|
||||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||||
p.prompt,
|
p.prompt,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (totpPrompt) {
|
if (totpPromptIndex !== -1) {
|
||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
|
|
||||||
if (pendingTOTPSessions[sessionId]) {
|
if (pendingTOTPSessions[sessionId]) {
|
||||||
fileLogger.warn(
|
fileLogger.warn(
|
||||||
"TOTP session already exists, cleaning up old client",
|
"TOTP session already exists, ignoring duplicate keyboard-interactive",
|
||||||
{
|
{
|
||||||
operation: "file_keyboard_interactive",
|
operation: "file_keyboard_interactive",
|
||||||
hostId,
|
hostId,
|
||||||
sessionId,
|
sessionId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
try {
|
// Don't respond to duplicate keyboard-interactive events
|
||||||
pendingTOTPSessions[sessionId].client.end();
|
// The first one is still being processed
|
||||||
} catch (e) {
|
return;
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingTOTPSessions[sessionId] = {
|
pendingTOTPSessions[sessionId] = {
|
||||||
@@ -496,19 +497,23 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
userId,
|
userId,
|
||||||
|
prompts,
|
||||||
|
totpPromptIndex,
|
||||||
|
resolvedPassword: resolvedCredentials.password,
|
||||||
};
|
};
|
||||||
|
|
||||||
fileLogger.info("Created TOTP session", {
|
fileLogger.info("Created TOTP session", {
|
||||||
operation: "file_keyboard_interactive_totp",
|
operation: "file_keyboard_interactive_totp",
|
||||||
hostId,
|
hostId,
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: totpPrompt.prompt,
|
prompt: prompts[totpPromptIndex].prompt,
|
||||||
|
promptsCount: prompts.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
requires_totp: true,
|
requires_totp: true,
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: totpPrompt.prompt,
|
prompt: prompts[totpPromptIndex].prompt,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (resolvedCredentials.password) {
|
if (resolvedCredentials.password) {
|
||||||
@@ -580,15 +585,40 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
userId,
|
userId,
|
||||||
codeLength: totpCode.length,
|
codeLength: totpCode.length,
|
||||||
|
promptsCount: session.prompts?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
session.finish([totpCode]);
|
// Build responses for ALL prompts, just like in terminal.ts
|
||||||
|
const responses = (session.prompts || []).map((p, index) => {
|
||||||
|
if (index === session.totpPromptIndex) {
|
||||||
|
return totpCode;
|
||||||
|
}
|
||||||
|
if (/password/i.test(p.prompt) && session.resolvedPassword) {
|
||||||
|
return session.resolvedPassword;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
fileLogger.info("Full keyboard-interactive response for file manager", {
|
||||||
|
operation: "file_totp_full_response",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
totalPrompts: session.prompts?.length || 0,
|
||||||
|
responsesProvided: responses.filter((r) => r !== "").length,
|
||||||
|
});
|
||||||
|
|
||||||
let responseSent = false;
|
let responseSent = false;
|
||||||
|
let responseTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
session.client.on("ready", () => {
|
// Remove old event listeners from /connect endpoint to avoid conflicts
|
||||||
|
session.client.removeAllListeners("ready");
|
||||||
|
session.client.removeAllListeners("error");
|
||||||
|
|
||||||
|
// CRITICAL: Attach event listeners BEFORE calling finish() to avoid race condition
|
||||||
|
session.client.once("ready", () => {
|
||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
|
clearTimeout(responseTimeout);
|
||||||
|
|
||||||
delete pendingTOTPSessions[sessionId];
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
|
||||||
@@ -666,9 +696,10 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
session.client.on("error", (err) => {
|
session.client.once("error", (err) => {
|
||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
|
clearTimeout(responseTimeout);
|
||||||
|
|
||||||
delete pendingTOTPSessions[sessionId];
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
|
||||||
@@ -682,13 +713,21 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
|
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
responseTimeout = setTimeout(() => {
|
||||||
if (!responseSent) {
|
if (!responseSent) {
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
delete pendingTOTPSessions[sessionId];
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
fileLogger.warn("TOTP verification timeout", {
|
||||||
|
operation: "file_totp_verify",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
res.status(408).json({ error: "TOTP verification timeout" });
|
res.status(408).json({ error: "TOTP verification timeout" });
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
|
// Now that event listeners are attached, submit the TOTP response
|
||||||
|
session.finish(responses);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
|
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
|
||||||
|
|||||||
@@ -479,151 +479,155 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
sshConn.on("ready", () => {
|
sshConn.on("ready", () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
sshConn!.shell(
|
// Small delay to let connection stabilize after keyboard-interactive auth
|
||||||
{
|
// This helps prevent "No response from server" errors with TOTP
|
||||||
rows: data.rows,
|
setTimeout(() => {
|
||||||
cols: data.cols,
|
sshConn!.shell(
|
||||||
term: "xterm-256color",
|
{
|
||||||
} as PseudoTtyOptions,
|
rows: data.rows,
|
||||||
(err, stream) => {
|
cols: data.cols,
|
||||||
if (err) {
|
term: "xterm-256color",
|
||||||
sshLogger.error("Shell error", err, {
|
} as PseudoTtyOptions,
|
||||||
operation: "ssh_shell",
|
(err, stream) => {
|
||||||
hostId: id,
|
if (err) {
|
||||||
ip,
|
sshLogger.error("Shell error", err, {
|
||||||
port,
|
operation: "ssh_shell",
|
||||||
username,
|
|
||||||
});
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "error",
|
|
||||||
message: "Shell error: " + err.message,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sshStream = stream;
|
|
||||||
|
|
||||||
stream.on("data", (data: Buffer) => {
|
|
||||||
try {
|
|
||||||
const utf8String = data.toString("utf-8");
|
|
||||||
ws.send(JSON.stringify({ type: "data", data: utf8String }));
|
|
||||||
} catch (error) {
|
|
||||||
sshLogger.error("Error encoding terminal data", error, {
|
|
||||||
operation: "terminal_data_encoding",
|
|
||||||
hostId: id,
|
hostId: id,
|
||||||
dataLength: data.length,
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
});
|
});
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "data",
|
type: "error",
|
||||||
data: data.toString("latin1"),
|
message: "Shell error: " + err.message,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("close", () => {
|
sshStream = stream;
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "disconnected",
|
|
||||||
message: "Connection lost",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("error", (err: Error) => {
|
stream.on("data", (data: Buffer) => {
|
||||||
sshLogger.error("SSH stream error", err, {
|
|
||||||
operation: "ssh_stream",
|
|
||||||
hostId: id,
|
|
||||||
ip,
|
|
||||||
port,
|
|
||||||
username,
|
|
||||||
});
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "error",
|
|
||||||
message: "SSH stream error: " + err.message,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
setupPingInterval();
|
|
||||||
|
|
||||||
if (initialPath && initialPath.trim() !== "") {
|
|
||||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
|
||||||
stream.write(cdCommand);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (executeCommand && executeCommand.trim() !== "") {
|
|
||||||
setTimeout(() => {
|
|
||||||
const command = `${executeCommand}\n`;
|
|
||||||
stream.write(command);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log activity to homepage API
|
|
||||||
if (id && hostConfig.userId) {
|
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
// Fetch host name from database
|
const utf8String = data.toString("utf-8");
|
||||||
const hosts = await SimpleDBOps.select(
|
ws.send(JSON.stringify({ type: "data", data: utf8String }));
|
||||||
getDb()
|
} catch (error) {
|
||||||
.select()
|
sshLogger.error("Error encoding terminal data", error, {
|
||||||
.from(sshData)
|
operation: "terminal_data_encoding",
|
||||||
.where(
|
hostId: id,
|
||||||
and(
|
dataLength: data.length,
|
||||||
eq(sshData.id, id),
|
});
|
||||||
eq(sshData.userId, hostConfig.userId!),
|
ws.send(
|
||||||
),
|
JSON.stringify({
|
||||||
),
|
type: "data",
|
||||||
"ssh_data",
|
data: data.toString("latin1"),
|
||||||
hostConfig.userId!,
|
}),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const hostName =
|
stream.on("close", () => {
|
||||||
hosts.length > 0 && hosts[0].name
|
ws.send(
|
||||||
? hosts[0].name
|
JSON.stringify({
|
||||||
: `${username}@${ip}:${port}`;
|
type: "disconnected",
|
||||||
|
message: "Connection lost",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await axios.post(
|
stream.on("error", (err: Error) => {
|
||||||
"http://localhost:30006/activity/log",
|
sshLogger.error("SSH stream error", err, {
|
||||||
{
|
operation: "ssh_stream",
|
||||||
type: "terminal",
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "SSH stream error: " + err.message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setupPingInterval();
|
||||||
|
|
||||||
|
if (initialPath && initialPath.trim() !== "") {
|
||||||
|
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||||
|
stream.write(cdCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executeCommand && executeCommand.trim() !== "") {
|
||||||
|
setTimeout(() => {
|
||||||
|
const command = `${executeCommand}\n`;
|
||||||
|
stream.write(command);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log activity to homepage API
|
||||||
|
if (id && hostConfig.userId) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch host name from database
|
||||||
|
const hosts = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshData.id, id),
|
||||||
|
eq(sshData.userId, hostConfig.userId!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ssh_data",
|
||||||
|
hostConfig.userId!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hostName =
|
||||||
|
hosts.length > 0 && hosts[0].name
|
||||||
|
? hosts[0].name
|
||||||
|
: `${username}@${ip}:${port}`;
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
"http://localhost:30006/activity/log",
|
||||||
|
{
|
||||||
|
type: "terminal",
|
||||||
|
hostId: id,
|
||||||
|
hostName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
sshLogger.info("Terminal activity logged", {
|
||||||
|
operation: "activity_log",
|
||||||
|
userId: hostConfig.userId,
|
||||||
hostId: id,
|
hostId: id,
|
||||||
hostName,
|
hostName,
|
||||||
},
|
});
|
||||||
{
|
} catch (error) {
|
||||||
headers: {
|
sshLogger.warn("Failed to log terminal activity", {
|
||||||
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
|
operation: "activity_log_error",
|
||||||
},
|
userId: hostConfig.userId,
|
||||||
},
|
hostId: id,
|
||||||
);
|
error:
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
sshLogger.info("Terminal activity logged", {
|
});
|
||||||
operation: "activity_log",
|
}
|
||||||
userId: hostConfig.userId,
|
})();
|
||||||
hostId: id,
|
}
|
||||||
hostName,
|
},
|
||||||
});
|
);
|
||||||
} catch (error) {
|
}, 100); // Small delay to stabilize connection after keyboard-interactive auth
|
||||||
sshLogger.warn("Failed to log terminal activity", {
|
|
||||||
operation: "activity_log_error",
|
|
||||||
userId: hostConfig.userId,
|
|
||||||
hostId: id,
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "Unknown error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sshConn.on("error", (err: Error) => {
|
sshConn.on("error", (err: Error) => {
|
||||||
@@ -716,19 +720,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
keyboardInteractiveFinish = (totpResponses: string[]) => {
|
keyboardInteractiveFinish = (totpResponses: string[]) => {
|
||||||
const totpCode = (totpResponses[0] || "").trim();
|
const totpCode = (totpResponses[0] || "").trim();
|
||||||
|
|
||||||
sshLogger.info("TOTP response being sent to SSH server", {
|
|
||||||
operation: "totp_verification",
|
|
||||||
hostId: id,
|
|
||||||
responseLength: totpCode.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[SSH TOTP Response] Host ${id}: TOTP code: "${totpCode}" (length: ${totpCode.length})`,
|
|
||||||
);
|
|
||||||
console.log(`[SSH TOTP Response] Calling finish() with array:`, [
|
|
||||||
totpCode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Respond to ALL prompts, not just TOTP
|
// Respond to ALL prompts, not just TOTP
|
||||||
const responses = prompts.map((p, index) => {
|
const responses = prompts.map((p, index) => {
|
||||||
if (index === totpPromptIndex) {
|
if (index === totpPromptIndex) {
|
||||||
@@ -740,9 +731,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
sshLogger.info("Full keyboard-interactive response", {
|
sshLogger.info("TOTP response being sent to SSH server", {
|
||||||
operation: "totp_full_response",
|
operation: "totp_verification",
|
||||||
hostId: id,
|
hostId: id,
|
||||||
|
totpCodeLength: totpCode.length,
|
||||||
totalPrompts: prompts.length,
|
totalPrompts: prompts.length,
|
||||||
responsesProvided: responses.filter((r) => r !== "").length,
|
responsesProvided: responses.filter((r) => r !== "").length,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export interface TabContextTab {
|
|||||||
title: string;
|
title: string;
|
||||||
hostConfig?: SSHHost;
|
hostConfig?: SSHHost;
|
||||||
terminalRef?: any;
|
terminalRef?: any;
|
||||||
|
initialTab?: string; // For ssh_manager: "host_viewer" | "add_host" | "credentials" | "add_credential"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -339,6 +340,7 @@ export interface CredentialSelectorProps {
|
|||||||
export interface HostManagerProps {
|
export interface HostManagerProps {
|
||||||
onSelectView?: (view: string) => void;
|
onSelectView?: (view: string) => void;
|
||||||
isTopbarOpen?: boolean;
|
isTopbarOpen?: boolean;
|
||||||
|
initialTab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHManagerHostEditorProps {
|
export interface SSHManagerHostEditorProps {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function Dashboard({
|
|||||||
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const { addTab } = useTabs();
|
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
|
||||||
|
|
||||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||||
try {
|
try {
|
||||||
@@ -160,14 +160,27 @@ export function Dashboard({
|
|||||||
const hosts = await getSSHHosts();
|
const hosts = await getSSHHosts();
|
||||||
setTotalServers(hosts.length);
|
setTotalServers(hosts.length);
|
||||||
|
|
||||||
const tunnels = await getTunnelStatuses();
|
// Count total tunnels across all hosts
|
||||||
setTotalTunnels(Object.keys(tunnels).length);
|
let totalTunnelsCount = 0;
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (host.tunnelConnections) {
|
||||||
|
try {
|
||||||
|
const tunnelConnections = JSON.parse(host.tunnelConnections);
|
||||||
|
if (Array.isArray(tunnelConnections)) {
|
||||||
|
totalTunnelsCount += tunnelConnections.length;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTotalTunnels(totalTunnelsCount);
|
||||||
|
|
||||||
const credentials = await getCredentials();
|
const credentials = await getCredentials();
|
||||||
setTotalCredentials(credentials.length);
|
setTotalCredentials(credentials.length);
|
||||||
|
|
||||||
// Fetch recent activity
|
// Fetch recent activity (35 items)
|
||||||
const activity = await getRecentActivity(10);
|
const activity = await getRecentActivity(35);
|
||||||
setRecentActivity(activity);
|
setRecentActivity(activity);
|
||||||
|
|
||||||
// Fetch server stats for first 5 servers
|
// Fetch server stats for first 5 servers
|
||||||
@@ -237,6 +250,55 @@ export function Dashboard({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Quick Actions handlers
|
||||||
|
const handleAddHost = () => {
|
||||||
|
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||||
|
if (sshManagerTab) {
|
||||||
|
setCurrentTab(sshManagerTab.id);
|
||||||
|
} else {
|
||||||
|
const id = addTab({
|
||||||
|
type: "ssh_manager",
|
||||||
|
title: "Host Manager",
|
||||||
|
initialTab: "add_host",
|
||||||
|
});
|
||||||
|
setCurrentTab(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCredential = () => {
|
||||||
|
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||||
|
if (sshManagerTab) {
|
||||||
|
setCurrentTab(sshManagerTab.id);
|
||||||
|
} else {
|
||||||
|
const id = addTab({
|
||||||
|
type: "ssh_manager",
|
||||||
|
title: "Host Manager",
|
||||||
|
initialTab: "add_credential",
|
||||||
|
});
|
||||||
|
setCurrentTab(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAdminSettings = () => {
|
||||||
|
const adminTab = tabList.find((t) => t.type === "admin");
|
||||||
|
if (adminTab) {
|
||||||
|
setCurrentTab(adminTab.id);
|
||||||
|
} else {
|
||||||
|
const id = addTab({ type: "admin", title: "Admin Settings" });
|
||||||
|
setCurrentTab(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenUserProfile = () => {
|
||||||
|
const userProfileTab = tabList.find((t) => t.type === "user_profile");
|
||||||
|
if (userProfileTab) {
|
||||||
|
setCurrentTab(userProfileTab.id);
|
||||||
|
} else {
|
||||||
|
const id = addTab({ type: "user_profile", title: "User Profile" });
|
||||||
|
setCurrentTab(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!loggedIn ? (
|
{!loggedIn ? (
|
||||||
@@ -486,7 +548,7 @@ export function Dashboard({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||||
onClick={() => onSelectView("host-manager-add")}
|
onClick={handleAddHost}
|
||||||
>
|
>
|
||||||
<Server
|
<Server
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
@@ -499,7 +561,7 @@ export function Dashboard({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||||
onClick={() => onSelectView("host-manager-credentials")}
|
onClick={handleAddCredential}
|
||||||
>
|
>
|
||||||
<Key
|
<Key
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
@@ -513,7 +575,7 @@ export function Dashboard({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||||
onClick={() => onSelectView("admin-settings")}
|
onClick={handleOpenAdminSettings}
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
@@ -527,7 +589,7 @@ export function Dashboard({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||||
onClick={() => onSelectView("user-profile")}
|
onClick={handleOpenUserProfile}
|
||||||
>
|
>
|
||||||
<User
|
<User
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
removeRecentFile,
|
removeRecentFile,
|
||||||
addFolderShortcut,
|
addFolderShortcut,
|
||||||
getPinnedFiles,
|
getPinnedFiles,
|
||||||
|
logActivity,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import type { SidebarItem } from "./FileManagerSidebar";
|
import type { SidebarItem } from "./FileManagerSidebar";
|
||||||
|
|
||||||
@@ -298,6 +299,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
|
|
||||||
setSshSessionId(sessionId);
|
setSshSessionId(sessionId);
|
||||||
|
|
||||||
|
// Log activity for recent connections
|
||||||
|
if (currentHost?.id) {
|
||||||
|
const hostName =
|
||||||
|
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
|
||||||
|
logActivity("file_manager", currentHost.id, hostName).catch((err) => {
|
||||||
|
console.warn("Failed to log file manager activity:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await listSSHFiles(sessionId, currentPath);
|
const response = await listSSHFiles(sessionId, currentPath);
|
||||||
const files = Array.isArray(response)
|
const files = Array.isArray(response)
|
||||||
@@ -1247,6 +1257,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
setSshSessionId(totpSessionId);
|
setSshSessionId(totpSessionId);
|
||||||
setTotpSessionId(null);
|
setTotpSessionId(null);
|
||||||
|
|
||||||
|
// Log activity for recent connections
|
||||||
|
if (currentHost?.id) {
|
||||||
|
const hostName =
|
||||||
|
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
|
||||||
|
logActivity("file_manager", currentHost.id, hostName).catch((err) => {
|
||||||
|
console.warn("Failed to log file manager activity:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await listSSHFiles(totpSessionId, currentPath);
|
const response = await listSSHFiles(totpSessionId, currentPath);
|
||||||
const files = Array.isArray(response)
|
const files = Array.isArray(response)
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import type { SSHHost, HostManagerProps } from "../../../types/index";
|
|||||||
|
|
||||||
export function HostManager({
|
export function HostManager({
|
||||||
isTopbarOpen,
|
isTopbarOpen,
|
||||||
|
initialTab = "host_viewer",
|
||||||
}: HostManagerProps): React.ReactElement {
|
}: HostManagerProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||||
|
|
||||||
const [editingCredential, setEditingCredential] = useState<{
|
const [editingCredential, setEditingCredential] = useState<{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
|
|||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
import { getCookie, isElectron, logActivity } from "@/ui/main-axios.ts";
|
||||||
import { TOTPDialog } from "@/ui/components/TOTPDialog";
|
import { TOTPDialog } from "@/ui/components/TOTPDialog";
|
||||||
|
|
||||||
interface HostConfig {
|
interface HostConfig {
|
||||||
@@ -469,6 +469,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
reconnectAttempts.current = 0;
|
reconnectAttempts.current = 0;
|
||||||
isReconnectingRef.current = false;
|
isReconnectingRef.current = false;
|
||||||
|
|
||||||
|
// Log activity for recent connections
|
||||||
|
if (hostConfig.id) {
|
||||||
|
const hostName =
|
||||||
|
hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`;
|
||||||
|
logActivity("terminal", hostConfig.id, hostName).catch((err) => {
|
||||||
|
console.warn("Failed to log terminal activity:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (msg.type === "disconnected") {
|
} else if (msg.type === "disconnected") {
|
||||||
wasDisconnectedBySSH.current = true;
|
wasDisconnectedBySSH.current = true;
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ function AppContent() {
|
|||||||
<HostManager
|
<HostManager
|
||||||
onSelectView={handleSelectView}
|
onSelectView={handleSelectView}
|
||||||
isTopbarOpen={isTopbarOpen}
|
isTopbarOpen={isTopbarOpen}
|
||||||
|
initialTab={currentTabData?.initialTab}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ interface TabProps {
|
|||||||
disableActivate?: boolean;
|
disableActivate?: boolean;
|
||||||
disableSplit?: boolean;
|
disableSplit?: boolean;
|
||||||
disableClose?: boolean;
|
disableClose?: boolean;
|
||||||
onDragStart?: () => void;
|
|
||||||
onDragOver?: (e: React.DragEvent) => void;
|
|
||||||
onDragLeave?: () => void;
|
|
||||||
onDrop?: (e: React.DragEvent) => void;
|
|
||||||
onDragEnd?: () => void;
|
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
isDragOver?: boolean;
|
isDragOver?: boolean;
|
||||||
}
|
}
|
||||||
@@ -45,52 +40,37 @@ export function Tab({
|
|||||||
disableActivate = false,
|
disableActivate = false,
|
||||||
disableSplit = false,
|
disableSplit = false,
|
||||||
disableClose = false,
|
disableClose = false,
|
||||||
onDragStart,
|
|
||||||
onDragOver,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop,
|
|
||||||
onDragEnd,
|
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
isDragOver = false,
|
isDragOver = false,
|
||||||
}: TabProps): React.ReactElement {
|
}: TabProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const dragProps = {
|
|
||||||
draggable: true,
|
|
||||||
onDragStart,
|
|
||||||
onDragOver,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop,
|
|
||||||
onDragEnd,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Firefox-style tab classes using cn utility
|
// Firefox-style tab classes using cn utility
|
||||||
const tabBaseClasses = cn(
|
const tabBaseClasses = cn(
|
||||||
"relative flex items-center gap-1.5 px-3 py-2 min-w-fit max-w-[200px]",
|
"relative flex items-center gap-1.5 px-3 min-w-fit max-w-[200px]",
|
||||||
"rounded-t-lg border-t-2 border-l-2 border-r-2",
|
"rounded-t-lg border-t-2 border-l-2 border-r-2",
|
||||||
"transition-all duration-150 select-none",
|
"transition-all duration-150 h-[42px]",
|
||||||
isDragOver &&
|
isDragOver &&
|
||||||
"bg-background/40 text-muted-foreground border-border opacity-60 cursor-default",
|
"bg-background/40 text-muted-foreground border-border opacity-60",
|
||||||
isDragging && "opacity-40 cursor-grabbing",
|
isDragging && "opacity-70",
|
||||||
!isDragOver &&
|
!isDragOver &&
|
||||||
!isDragging &&
|
!isDragging &&
|
||||||
isActive &&
|
isActive &&
|
||||||
"bg-background text-foreground border-border z-10 cursor-pointer",
|
"bg-background text-foreground border-border z-10",
|
||||||
!isDragOver &&
|
!isDragOver &&
|
||||||
!isDragging &&
|
!isDragging &&
|
||||||
!isActive &&
|
!isActive &&
|
||||||
"bg-background/80 text-muted-foreground border-border hover:bg-background/90 cursor-pointer",
|
"bg-background/80 text-muted-foreground border-border hover:bg-background/90",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tabType === "home") {
|
if (tabType === "home") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={tabBaseClasses}
|
className={tabBaseClasses}
|
||||||
{...dragProps}
|
|
||||||
onClick={!disableActivate ? onActivate : undefined}
|
onClick={!disableActivate ? onActivate : undefined}
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "-2px",
|
marginBottom: "-2px",
|
||||||
borderBottom: isActive ? "2px solid transparent" : "none",
|
borderBottom: isActive ? "2px solid white" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4" />
|
<Home className="h-4 w-4" />
|
||||||
@@ -121,10 +101,9 @@ export function Tab({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={tabBaseClasses}
|
className={tabBaseClasses}
|
||||||
{...dragProps}
|
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "-2px",
|
marginBottom: "-2px",
|
||||||
borderBottom: isActive ? "2px solid transparent" : "none",
|
borderBottom: isActive ? "2px solid white" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -183,10 +162,9 @@ export function Tab({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={tabBaseClasses}
|
className={tabBaseClasses}
|
||||||
{...dragProps}
|
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "-2px",
|
marginBottom: "-2px",
|
||||||
borderBottom: isActive ? "2px solid transparent" : "none",
|
borderBottom: isActive ? "2px solid white" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -220,10 +198,9 @@ export function Tab({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={tabBaseClasses}
|
className={tabBaseClasses}
|
||||||
{...dragProps}
|
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "-2px",
|
marginBottom: "-2px",
|
||||||
borderBottom: isActive ? "2px solid transparent" : "none",
|
borderBottom: isActive ? "2px solid white" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -58,8 +58,19 @@ export function TopNavbar({
|
|||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||||
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false);
|
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false);
|
||||||
const [draggedTabIndex, setDraggedTabIndex] = useState<number | null>(null);
|
const [dragState, setDragState] = useState<{
|
||||||
const [dragOverTabIndex, setDragOverTabIndex] = useState<number | null>(null);
|
draggedIndex: number | null;
|
||||||
|
currentX: number;
|
||||||
|
startX: number;
|
||||||
|
targetIndex: number | null;
|
||||||
|
}>({
|
||||||
|
draggedIndex: null,
|
||||||
|
currentX: 0,
|
||||||
|
startX: 0,
|
||||||
|
targetIndex: null,
|
||||||
|
});
|
||||||
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const tabRefs = React.useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
|
||||||
const handleTabActivate = (tabId: number) => {
|
const handleTabActivate = (tabId: number) => {
|
||||||
setCurrentTab(tabId);
|
setCurrentTab(tabId);
|
||||||
@@ -238,33 +249,110 @@ export function TopNavbar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragStart = (index: number) => {
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||||
setDraggedTabIndex(index);
|
console.log("Drag start:", index, e.clientX);
|
||||||
|
|
||||||
|
// Create transparent drag image
|
||||||
|
const img = new Image();
|
||||||
|
img.src =
|
||||||
|
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||||
|
e.dataTransfer.setDragImage(img, 0, 0);
|
||||||
|
|
||||||
|
setDragState({
|
||||||
|
draggedIndex: index,
|
||||||
|
startX: e.clientX,
|
||||||
|
currentX: e.clientX,
|
||||||
|
targetIndex: index,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
if (e.clientX === 0) return; // Skip the final drag event
|
||||||
if (draggedTabIndex !== null && draggedTabIndex !== index) {
|
if (dragState.draggedIndex === null) return;
|
||||||
setDragOverTabIndex(index);
|
|
||||||
|
console.log("Dragging:", e.clientX);
|
||||||
|
|
||||||
|
setDragState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
currentX: e.clientX,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate target position based on mouse X
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - containerRect.left;
|
||||||
|
|
||||||
|
let accumulatedX = 0;
|
||||||
|
let newTargetIndex = dragState.draggedIndex;
|
||||||
|
|
||||||
|
tabs.forEach((tab, i) => {
|
||||||
|
const tabEl = tabRefs.current.get(i);
|
||||||
|
if (!tabEl) return;
|
||||||
|
|
||||||
|
const tabWidth = tabEl.getBoundingClientRect().width;
|
||||||
|
const tabCenter = accumulatedX + tabWidth / 2;
|
||||||
|
|
||||||
|
if (mouseX < tabCenter && i === 0) {
|
||||||
|
newTargetIndex = 0;
|
||||||
|
} else if (mouseX >= tabCenter && mouseX < accumulatedX + tabWidth) {
|
||||||
|
newTargetIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulatedX += tabWidth + 4; // 4px gap
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mouseX >= accumulatedX - 4) {
|
||||||
|
newTargetIndex = tabs.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDragState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
targetIndex: newTargetIndex,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
setDragOverTabIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedTabIndex !== null && draggedTabIndex !== dropIndex) {
|
};
|
||||||
reorderTabs(draggedTabIndex, dropIndex);
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log("Drop:", dragState);
|
||||||
|
|
||||||
|
if (
|
||||||
|
dragState.draggedIndex !== null &&
|
||||||
|
dragState.targetIndex !== null &&
|
||||||
|
dragState.draggedIndex !== dragState.targetIndex
|
||||||
|
) {
|
||||||
|
reorderTabs(dragState.draggedIndex, dragState.targetIndex);
|
||||||
}
|
}
|
||||||
setDraggedTabIndex(null);
|
|
||||||
setDragOverTabIndex(null);
|
setDragState({
|
||||||
|
draggedIndex: null,
|
||||||
|
startX: 0,
|
||||||
|
currentX: 0,
|
||||||
|
targetIndex: null,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
setDraggedTabIndex(null);
|
console.log("Drag end:", dragState);
|
||||||
setDragOverTabIndex(null);
|
|
||||||
|
if (
|
||||||
|
dragState.draggedIndex !== null &&
|
||||||
|
dragState.targetIndex !== null &&
|
||||||
|
dragState.draggedIndex !== dragState.targetIndex
|
||||||
|
) {
|
||||||
|
reorderTabs(dragState.draggedIndex, dragState.targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragState({
|
||||||
|
draggedIndex: null,
|
||||||
|
startX: 0,
|
||||||
|
currentX: 0,
|
||||||
|
targetIndex: null,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSplitScreenActive =
|
const isSplitScreenActive =
|
||||||
@@ -284,20 +372,19 @@ export function TopNavbar({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="fixed z-10 h-[50px] bg-dark-bg border-2 border-dark-border rounded-lg transition-all duration-200 ease-linear flex flex-row transform-none m-0 p-0"
|
className="fixed z-10 h-[50px] border-2 border-dark-border rounded-lg transition-all duration-200 ease-linear flex flex-row transform-none m-0 p-0"
|
||||||
style={{
|
style={{
|
||||||
top: isTopbarOpen ? "0.5rem" : "-3rem",
|
top: isTopbarOpen ? "0.5rem" : "-3rem",
|
||||||
left: leftPosition,
|
left: leftPosition,
|
||||||
right: "17px",
|
right: "17px",
|
||||||
|
backgroundColor: "#1e1e21",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-full p-1 pr-2 border-r-2 border-dark-border w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-1 thin-scrollbar">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full p-1 pr-2 border-r-2 border-dark-border w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-1 thin-scrollbar"
|
||||||
|
>
|
||||||
{tabs.map((tab: TabData, index: number) => {
|
{tabs.map((tab: TabData, index: number) => {
|
||||||
// Insert preview tab before this position if dragging over it
|
|
||||||
const showPreviewBefore =
|
|
||||||
draggedTabIndex !== null &&
|
|
||||||
dragOverTabIndex === index &&
|
|
||||||
draggedTabIndex > index;
|
|
||||||
const isActive = tab.id === currentTab;
|
const isActive = tab.id === currentTab;
|
||||||
const isSplit =
|
const isSplit =
|
||||||
Array.isArray(allSplitScreenTab) &&
|
Array.isArray(allSplitScreenTab) &&
|
||||||
@@ -328,40 +415,77 @@ export function TopNavbar({
|
|||||||
tab.type === "user_profile") &&
|
tab.type === "user_profile") &&
|
||||||
isSplitScreenActive);
|
isSplitScreenActive);
|
||||||
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
||||||
const isDragging = draggedTabIndex === index;
|
|
||||||
const isDragOver = dragOverTabIndex === index;
|
|
||||||
|
|
||||||
// Show preview after this position if dragging over and coming from before
|
const isDragging = dragState.draggedIndex === index;
|
||||||
const showPreviewAfter =
|
const dragOffset = isDragging
|
||||||
draggedTabIndex !== null &&
|
? dragState.currentX - dragState.startX
|
||||||
dragOverTabIndex === index &&
|
: 0;
|
||||||
draggedTabIndex < index;
|
|
||||||
|
|
||||||
const draggedTab =
|
// Calculate transform
|
||||||
draggedTabIndex !== null ? tabs[draggedTabIndex] : null;
|
let transform = "";
|
||||||
|
if (isDragging) {
|
||||||
|
// Dragged tab follows cursor
|
||||||
|
transform = `translateX(${dragOffset}px)`;
|
||||||
|
} else if (
|
||||||
|
dragState.draggedIndex !== null &&
|
||||||
|
dragState.targetIndex !== null
|
||||||
|
) {
|
||||||
|
// Other tabs shift to make room
|
||||||
|
const draggedIndex = dragState.draggedIndex;
|
||||||
|
const targetIndex = dragState.targetIndex;
|
||||||
|
|
||||||
|
if (
|
||||||
|
draggedIndex < targetIndex &&
|
||||||
|
index > draggedIndex &&
|
||||||
|
index <= targetIndex
|
||||||
|
) {
|
||||||
|
// Shifting left
|
||||||
|
const draggedTabEl = tabRefs.current.get(draggedIndex);
|
||||||
|
const draggedWidth =
|
||||||
|
draggedTabEl?.getBoundingClientRect().width || 0;
|
||||||
|
transform = `translateX(-${draggedWidth + 4}px)`;
|
||||||
|
} else if (
|
||||||
|
draggedIndex > targetIndex &&
|
||||||
|
index >= targetIndex &&
|
||||||
|
index < draggedIndex
|
||||||
|
) {
|
||||||
|
// Shifting right
|
||||||
|
const draggedTabEl = tabRefs.current.get(draggedIndex);
|
||||||
|
const draggedWidth =
|
||||||
|
draggedTabEl?.getBoundingClientRect().width || 0;
|
||||||
|
transform = `translateX(${draggedWidth + 4}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={tab.id}>
|
<div
|
||||||
{/* Preview tab before current position */}
|
key={tab.id}
|
||||||
{showPreviewBefore && draggedTab && (
|
ref={(el) => {
|
||||||
<Tab
|
if (el) {
|
||||||
tabType={draggedTab.type}
|
tabRefs.current.set(index, el);
|
||||||
title={draggedTab.title}
|
} else {
|
||||||
isActive={false}
|
tabRefs.current.delete(index);
|
||||||
canSplit={
|
}
|
||||||
draggedTab.type === "terminal" ||
|
}}
|
||||||
draggedTab.type === "server" ||
|
draggable={true}
|
||||||
draggedTab.type === "file_manager"
|
onDragStart={(e) => {
|
||||||
}
|
e.stopPropagation();
|
||||||
canClose={true}
|
handleDragStart(e, index);
|
||||||
disableActivate={true}
|
}}
|
||||||
disableSplit={true}
|
onDrag={handleDrag}
|
||||||
disableClose={true}
|
onDragOver={handleDragOver}
|
||||||
isDragging={false}
|
onDrop={handleDrop}
|
||||||
isDragOver={true}
|
onDragEnd={handleDragEnd}
|
||||||
/>
|
style={{
|
||||||
)}
|
transform,
|
||||||
|
transition: isDragging ? "none" : "transform 200ms ease-out",
|
||||||
|
zIndex: isDragging ? 1000 : 1,
|
||||||
|
position: "relative",
|
||||||
|
cursor: isDragging ? "grabbing" : "grab",
|
||||||
|
userSelect: "none",
|
||||||
|
WebkitUserSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tab
|
<Tab
|
||||||
tabType={tab.type}
|
tabType={tab.type}
|
||||||
title={tab.title}
|
title={tab.title}
|
||||||
@@ -392,35 +516,10 @@ export function TopNavbar({
|
|||||||
disableActivate={disableActivate}
|
disableActivate={disableActivate}
|
||||||
disableSplit={disableSplit}
|
disableSplit={disableSplit}
|
||||||
disableClose={disableClose}
|
disableClose={disableClose}
|
||||||
onDragStart={() => handleDragStart(index)}
|
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={(e) => handleDrop(e, index)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
isDragOver={false}
|
isDragOver={false}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/* Preview tab after current position */}
|
|
||||||
{showPreviewAfter && draggedTab && (
|
|
||||||
<Tab
|
|
||||||
tabType={draggedTab.type}
|
|
||||||
title={draggedTab.title}
|
|
||||||
isActive={false}
|
|
||||||
canSplit={
|
|
||||||
draggedTab.type === "terminal" ||
|
|
||||||
draggedTab.type === "server" ||
|
|
||||||
draggedTab.type === "file_manager"
|
|
||||||
}
|
|
||||||
canClose={true}
|
|
||||||
disableActivate={true}
|
|
||||||
disableSplit={true}
|
|
||||||
disableClose={true}
|
|
||||||
isDragging={false}
|
|
||||||
isDragOver={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -460,7 +559,8 @@ export function TopNavbar({
|
|||||||
{!isTopbarOpen && (
|
{!isTopbarOpen && (
|
||||||
<div
|
<div
|
||||||
onClick={() => setIsTopbarOpen(true)}
|
onClick={() => setIsTopbarOpen(true)}
|
||||||
className="absolute top-0 left-0 w-full h-[10px] bg-dark-bg cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md"
|
className="absolute top-0 left-0 w-full h-[10px] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md"
|
||||||
|
style={{ backgroundColor: "#1e1e21" }}
|
||||||
>
|
>
|
||||||
<ChevronDown size={10} />
|
<ChevronDown size={10} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2042,7 +2042,7 @@ export async function getVersionInfo(): Promise<Record<string, unknown>> {
|
|||||||
|
|
||||||
export async function getDatabaseHealth(): Promise<Record<string, unknown>> {
|
export async function getDatabaseHealth(): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/users/db-health");
|
const response = await authApi.get("/health");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "check database health");
|
handleApiError(error, "check database health");
|
||||||
|
|||||||
Reference in New Issue
Block a user