v1.8.0 #429
@@ -2,7 +2,7 @@
|
||||
FROM node:22-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -82,8 +82,8 @@ COPY --chown=node:node package.json ./
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005
|
||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005 300006
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
CMD ["/entrypoint.sh"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -92,7 +92,7 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
@@ -101,18 +101,18 @@ http {
|
||||
location ~ ^/database(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
@@ -120,18 +120,18 @@ http {
|
||||
location ~ ^/db(/.*)?$ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
@@ -216,18 +216,18 @@ http {
|
||||
location /ssh/file_manager/ssh/ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
@@ -259,9 +259,27 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/uptime(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/activity(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,24 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/uptime(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/activity(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
@@ -189,3 +189,18 @@ export const snippets = sqliteTable("snippets", {
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const recentActivity = sqliteTable("recent_activity", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
type: text("type").notNull(),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
hostName: text("host_name").notNull(),
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
265
src/backend/homepage.ts
Normal file
265
src/backend/homepage.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { getDb } from "./database/db/index.js";
|
||||
import { recentActivity, sshData } from "./database/db/schema.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { homepageLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../types/index.js";
|
||||
|
||||
const app = express();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
// Track server start time
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
// Get server uptime
|
||||
app.get("/uptime", async (req, res) => {
|
||||
try {
|
||||
const uptimeMs = Date.now() - serverStartTime;
|
||||
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
|
||||
res.json({
|
||||
uptimeMs,
|
||||
uptimeSeconds,
|
||||
formatted: `${days}d ${hours}h ${minutes}m`,
|
||||
});
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to get uptime", err);
|
||||
res.status(500).json({ error: "Failed to get uptime" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent activity for current user
|
||||
app.get("/activity/recent", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
|
||||
const activities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp))
|
||||
.limit(limit),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
res.json(activities);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to get recent activity", err);
|
||||
res.status(500).json({ error: "Failed to get recent activity" });
|
||||
}
|
||||
});
|
||||
|
||||
// Log new activity
|
||||
app.post("/activity/log", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const { type, hostId, hostName } = req.body;
|
||||
|
||||
if (!type || !hostId || !hostName) {
|
||||
return res.status(400).json({
|
||||
error: "Missing required fields: type, hostId, hostName",
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== "terminal" && type !== "file_manager") {
|
||||
return res.status(400).json({
|
||||
error: "Invalid activity type. Must be 'terminal' or 'file_manager'",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the host belongs to the user
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
// Check if this activity already exists in recent history (within last 5 minutes)
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
const recentSimilar = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(
|
||||
and(
|
||||
eq(recentActivity.userId, userId),
|
||||
eq(recentActivity.hostId, hostId),
|
||||
eq(recentActivity.type, type),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(recentActivity.timestamp))
|
||||
.limit(1),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (
|
||||
recentSimilar.length > 0 &&
|
||||
recentSimilar[0].timestamp >= fiveMinutesAgo
|
||||
) {
|
||||
// Activity already logged recently, don't duplicate
|
||||
return res.json({
|
||||
message: "Activity already logged recently",
|
||||
id: recentSimilar[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
// Insert new activity
|
||||
const result = (await SimpleDBOps.insert(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
{
|
||||
userId,
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
},
|
||||
userId,
|
||||
)) as { id: number };
|
||||
|
||||
// Keep only the last 100 activities per user to prevent bloat
|
||||
const allActivities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (allActivities.length > 100) {
|
||||
const toDelete = allActivities.slice(100);
|
||||
for (const activity of toDelete) {
|
||||
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to log activity", err);
|
||||
res.status(500).json({ error: "Failed to log activity" });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset recent activity for current user
|
||||
app.delete("/activity/reset", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete all activities for the user
|
||||
const activities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
for (const activity of activities) {
|
||||
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
|
||||
}
|
||||
|
||||
res.json({ message: "Recent activity cleared" });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to reset activity", err);
|
||||
res.status(500).json({ error: "Failed to reset activity" });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = 30006;
|
||||
app.listen(PORT, async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
homepageLogger.success(`Homepage API listening on port ${PORT}`, {
|
||||
operation: "server_start",
|
||||
port: PORT,
|
||||
});
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { sshCredentials, sshData } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
@@ -101,6 +102,11 @@ interface PendingTOTPSession {
|
||||
config: import("ssh2").ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
@@ -365,6 +371,56 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({ status: "success", message: "SSH connection established" });
|
||||
|
||||
// Log activity to homepage API
|
||||
if (hostId && userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${username}@${ip}:${port}`;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "file_manager",
|
||||
hostId,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
fileLogger.info("File manager activity logged", {
|
||||
operation: "activity_log",
|
||||
userId,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.warn("Failed to log file manager activity", {
|
||||
operation: "activity_log_error",
|
||||
userId,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
@@ -435,6 +491,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
userId,
|
||||
};
|
||||
|
||||
fileLogger.info("Created TOTP session", {
|
||||
@@ -548,6 +609,61 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
status: "success",
|
||||
message: "TOTP verified, SSH connection established",
|
||||
});
|
||||
|
||||
// Log activity to homepage API
|
||||
if (session.hostId && session.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.id, session.hostId!),
|
||||
eq(sshData.userId, session.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
session.userId!,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${session.username}@${session.ip}:${session.port}`;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "file_manager",
|
||||
hostId: session.hostId,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
fileLogger.info("File manager activity logged (TOTP)", {
|
||||
operation: "activity_log",
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
hostName,
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.warn("Failed to log file manager activity (TOTP)", {
|
||||
operation: "activity_log_error",
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
session.client.on("error", (err) => {
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
type ConnectConfig,
|
||||
} from "ssh2";
|
||||
import { parse as parseUrl } from "url";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { sshCredentials, sshData } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
@@ -565,6 +566,62 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
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,
|
||||
hostName,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to log terminal activity", {
|
||||
operation: "activity_log_error",
|
||||
userId: hostConfig.userId,
|
||||
hostId: id,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -104,6 +104,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
await import("./ssh/server-stats.js");
|
||||
await import("./homepage.js");
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
|
||||
@@ -253,5 +253,6 @@ export const apiLogger = new Logger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#ec4899");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials";
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity";
|
||||
|
||||
class SimpleDBOps {
|
||||
static async insert<T extends Record<string, unknown>>(
|
||||
|
||||
@@ -379,5 +379,6 @@ export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
|
||||
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
|
||||
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
|
||||
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||
export const homepageLogger = new FrontendLogger("HOMEPAGE", "🏠", "#ec4899");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -3,9 +3,23 @@ import { Auth } from "@/ui/Desktop/Authentication/Auth.tsx";
|
||||
import { UpdateLog } from "@/ui/Desktop/Apps/Dashboard/Apps/UpdateLog.tsx";
|
||||
import { AlertManager } from "@/ui/Desktop/Apps/Dashboard/Apps/Alerts/AlertManager.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
getUserInfo,
|
||||
getDatabaseHealth,
|
||||
getCookie,
|
||||
getUptime,
|
||||
getVersionInfo,
|
||||
getSSHHosts,
|
||||
getTunnelStatuses,
|
||||
getCredentials,
|
||||
getRecentActivity,
|
||||
resetRecentActivity,
|
||||
getServerMetricsById,
|
||||
type RecentActivityItem,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {
|
||||
ChartLine,
|
||||
Clock,
|
||||
@@ -39,13 +53,33 @@ export function Dashboard({
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [, setIsAdmin] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
|
||||
// Dashboard data state
|
||||
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update"
|
||||
>("up_to_date");
|
||||
const [versionText, setVersionText] = useState<string>("v1.8.0");
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
const [totalServers, setTotalServers] = useState<number>(0);
|
||||
const [totalTunnels, setTotalTunnels] = useState<number>(0);
|
||||
const [totalCredentials, setTotalCredentials] = useState<number>(0);
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
|
||||
[],
|
||||
);
|
||||
const [serverStats, setServerStats] = useState<
|
||||
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
||||
>([]);
|
||||
|
||||
const { addTab } = useTabs();
|
||||
|
||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||
try {
|
||||
const sidebar = useSidebar();
|
||||
@@ -99,6 +133,110 @@ export function Dashboard({
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Fetch dashboard data
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
// Fetch uptime
|
||||
const uptimeInfo = await getUptime();
|
||||
setUptime(uptimeInfo.formatted);
|
||||
|
||||
// Fetch version info
|
||||
const versionInfo = await getVersionInfo();
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
setVersionStatus(versionInfo.status || "up_to_date");
|
||||
|
||||
// Fetch database health
|
||||
try {
|
||||
await getDatabaseHealth();
|
||||
setDbHealth("healthy");
|
||||
} catch {
|
||||
setDbHealth("error");
|
||||
}
|
||||
|
||||
// Fetch total counts
|
||||
const hosts = await getSSHHosts();
|
||||
setTotalServers(hosts.length);
|
||||
|
||||
const tunnels = await getTunnelStatuses();
|
||||
setTotalTunnels(Object.keys(tunnels).length);
|
||||
|
||||
const credentials = await getCredentials();
|
||||
setTotalCredentials(credentials.length);
|
||||
|
||||
// Fetch recent activity
|
||||
const activity = await getRecentActivity(10);
|
||||
setRecentActivity(activity);
|
||||
|
||||
// Fetch server stats for first 5 servers
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts.slice(0, 5).map(async (host: { id: number; name: string }) => {
|
||||
try {
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
setServerStats(serversWithStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loggedIn]);
|
||||
|
||||
// Handler for resetting recent activity
|
||||
const handleResetActivity = async () => {
|
||||
try {
|
||||
await resetRecentActivity();
|
||||
setRecentActivity([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset activity:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for opening a recent activity item
|
||||
const handleActivityClick = (item: RecentActivityItem) => {
|
||||
// Find the host and open appropriate tab
|
||||
getSSHHosts().then((hosts) => {
|
||||
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
|
||||
if (!host) return;
|
||||
|
||||
if (item.type === "terminal") {
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "file_manager") {
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loggedIn ? (
|
||||
@@ -201,14 +339,16 @@ export function Dashboard({
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
v1.8.0
|
||||
{versionText}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-2 text-sm border-1 text-green-400 border-dark-border"
|
||||
className={`ml-2 text-sm border-1 border-dark-border ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
|
||||
>
|
||||
Up to Date
|
||||
{versionStatus === "up_to_date"
|
||||
? "Up to Date"
|
||||
: "Update Available"}
|
||||
</Button>
|
||||
<UpdateLog loggedIn={loggedIn} />
|
||||
</div>
|
||||
@@ -226,7 +366,7 @@ export function Dashboard({
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
0d 0h 7m
|
||||
{uptime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,38 +382,55 @@ export function Dashboard({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
healthy
|
||||
<p
|
||||
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{dbHealth}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<Server
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">Total Servers</p>
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<Server
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">Total Servers</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalServers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<Network
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">Total Tunnels</p>
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<Network
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">Total Tunnels</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalTunnels}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<Key
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">Total Credentials</p>
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<Key
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">Total Credentials</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalCredentials}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,18 +446,31 @@ export function Dashboard({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-2 !border-dark-border h-7"
|
||||
onClick={handleResetActivity}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg"
|
||||
>
|
||||
<Server size={20} className="shrink-0" />
|
||||
<p className="truncate ml-2 font-semibold">Server #1</p>
|
||||
</Button>
|
||||
{recentActivity.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No recent activity
|
||||
</p>
|
||||
) : (
|
||||
recentActivity.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg"
|
||||
onClick={() => handleActivityClick(item)}
|
||||
>
|
||||
<Server size={20} className="shrink-0" />
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{item.hostName}
|
||||
</p>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,6 +486,7 @@ export function Dashboard({
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
onClick={() => onSelectView("host-manager-add")}
|
||||
>
|
||||
<Server
|
||||
className="shrink-0"
|
||||
@@ -328,6 +499,7 @@ export function Dashboard({
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
onClick={() => onSelectView("host-manager-credentials")}
|
||||
>
|
||||
<Key
|
||||
className="shrink-0"
|
||||
@@ -337,21 +509,25 @@ export function Dashboard({
|
||||
Add Credential
|
||||
</span>
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
onClick={() => onSelectView("admin-settings")}
|
||||
>
|
||||
<Settings
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span className="font-semibold text-sm mt-2">
|
||||
Admin Settings
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
>
|
||||
<Settings
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span className="font-semibold text-sm mt-2">
|
||||
Admin Settings
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
onClick={() => onSelectView("user-profile")}
|
||||
>
|
||||
<User
|
||||
className="shrink-0"
|
||||
@@ -371,23 +547,42 @@ export function Dashboard({
|
||||
Server Stats
|
||||
</p>
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg h-auto p-3"
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<Server size={20} className="shrink-0" />
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
Server #1
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-xs text-muted-foreground">
|
||||
<span>CPU: 45%</span>
|
||||
<span>RAM: 62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{serverStats.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No server data available
|
||||
</p>
|
||||
) : (
|
||||
serverStats.map((server) => (
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg h-auto p-3"
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<Server size={20} className="shrink-0" />
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{server.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
CPU:{" "}
|
||||
{server.cpu !== null
|
||||
? `${server.cpu}%`
|
||||
: "N/A"}
|
||||
</span>
|
||||
<span>
|
||||
RAM:{" "}
|
||||
{server.ram !== null
|
||||
? `${server.ram}%`
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { ButtonGroup } from "@/components/ui/button-group.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Home,
|
||||
SeparatorVertical,
|
||||
@@ -24,6 +24,13 @@ interface TabProps {
|
||||
disableActivate?: boolean;
|
||||
disableSplit?: boolean;
|
||||
disableClose?: boolean;
|
||||
onDragStart?: () => void;
|
||||
onDragOver?: (e: React.DragEvent) => void;
|
||||
onDragLeave?: () => void;
|
||||
onDrop?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: () => void;
|
||||
isDragging?: boolean;
|
||||
isDragOver?: boolean;
|
||||
}
|
||||
|
||||
export function Tab({
|
||||
@@ -38,18 +45,56 @@ export function Tab({
|
||||
disableActivate = false,
|
||||
disableSplit = false,
|
||||
disableClose = false,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
isDragging = false,
|
||||
isDragOver = false,
|
||||
}: TabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dragProps = {
|
||||
draggable: true,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
};
|
||||
|
||||
// Firefox-style tab classes using cn utility
|
||||
const tabBaseClasses = cn(
|
||||
"relative flex items-center gap-1.5 px-3 py-2 min-w-fit max-w-[200px]",
|
||||
"rounded-t-lg border-t-2 border-l-2 border-r-2",
|
||||
"transition-all duration-150 select-none",
|
||||
isDragOver &&
|
||||
"bg-background/40 text-muted-foreground border-border opacity-60 cursor-default",
|
||||
isDragging && "opacity-40 cursor-grabbing",
|
||||
!isDragOver &&
|
||||
!isDragging &&
|
||||
isActive &&
|
||||
"bg-background text-foreground border-border z-10 cursor-pointer",
|
||||
!isDragOver &&
|
||||
!isDragging &&
|
||||
!isActive &&
|
||||
"bg-background/80 text-muted-foreground border-border hover:bg-background/90 cursor-pointer",
|
||||
);
|
||||
|
||||
if (tabType === "home") {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
<div
|
||||
className={tabBaseClasses}
|
||||
{...dragProps}
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid transparent" : "none",
|
||||
}}
|
||||
>
|
||||
<Home />
|
||||
</Button>
|
||||
<Home className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,102 +107,147 @@ export function Tab({
|
||||
const isServer = tabType === "server";
|
||||
const isFileManager = tabType === "file_manager";
|
||||
const isUserProfile = tabType === "user_profile";
|
||||
|
||||
const displayTitle =
|
||||
title ||
|
||||
(isServer
|
||||
? t("nav.serverStats")
|
||||
: isFileManager
|
||||
? t("nav.fileManager")
|
||||
: isUserProfile
|
||||
? t("nav.userProfile")
|
||||
: t("nav.terminal"));
|
||||
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
<div
|
||||
className={tabBaseClasses}
|
||||
{...dragProps}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid transparent" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-1 min-w-0"
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
>
|
||||
{isServer ? (
|
||||
<ServerIcon className="mr-1 h-4 w-4" />
|
||||
<ServerIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isFileManager ? (
|
||||
<FolderIcon className="mr-1 h-4 w-4" />
|
||||
<FolderIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isUserProfile ? (
|
||||
<UserIcon className="mr-1 h-4 w-4" />
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<TerminalIcon className="mr-1 h-4 w-4" />
|
||||
<TerminalIcon className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{title ||
|
||||
(isServer
|
||||
? t("nav.serverStats")
|
||||
: isFileManager
|
||||
? t("nav.fileManager")
|
||||
: isUserProfile
|
||||
? t("nav.userProfile")
|
||||
: t("nav.terminal"))}
|
||||
</Button>
|
||||
<span className="truncate text-sm">{displayTitle}</span>
|
||||
</div>
|
||||
|
||||
{canSplit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={onSplit}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableSplit && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableSplit && onSplit) onSplit();
|
||||
}}
|
||||
disabled={disableSplit}
|
||||
title={
|
||||
disableSplit ? t("nav.cannotSplitTab") : t("nav.splitScreen")
|
||||
}
|
||||
>
|
||||
<SeparatorVertical className="w-[28px] h-[28px]" />
|
||||
<SeparatorVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableClose && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableClose && onClose) onClose();
|
||||
}}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X />
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "ssh_manager") {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
<div
|
||||
className={tabBaseClasses}
|
||||
{...dragProps}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid transparent" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-1 min-w-0"
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
>
|
||||
{title || t("nav.sshManager")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<span className="truncate text-sm">
|
||||
{title || t("nav.sshManager")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableClose && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableClose && onClose) onClose();
|
||||
}}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "admin") {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
<div
|
||||
className={tabBaseClasses}
|
||||
{...dragProps}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid transparent" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-1 min-w-0"
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
>
|
||||
{title || t("nav.admin")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<span className="truncate text-sm">{title || t("nav.admin")}</span>
|
||||
</div>
|
||||
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", disableClose && "opacity-50")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!disableClose && onClose) onClose();
|
||||
}}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ interface TabContextType {
|
||||
setCurrentTab: (tabId: number) => void;
|
||||
setSplitScreenTab: (tabId: number) => void;
|
||||
getTab: (tabId: number) => Tab | undefined;
|
||||
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||
updateHostConfig: (
|
||||
hostId: number,
|
||||
newHostConfig: {
|
||||
@@ -152,6 +153,15 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return tabs.find((tab) => tab.id === tabId);
|
||||
};
|
||||
|
||||
const reorderTabs = (fromIndex: number, toIndex: number) => {
|
||||
setTabs((prev) => {
|
||||
const newTabs = [...prev];
|
||||
const [movedTab] = newTabs.splice(fromIndex, 1);
|
||||
newTabs.splice(toIndex, 0, movedTab);
|
||||
return newTabs;
|
||||
});
|
||||
};
|
||||
|
||||
const updateHostConfig = (
|
||||
hostId: number,
|
||||
newHostConfig: {
|
||||
@@ -187,6 +197,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
setCurrentTab,
|
||||
setSplitScreenTab,
|
||||
getTab,
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export function TopNavbar({
|
||||
setSplitScreenTab,
|
||||
removeTab,
|
||||
allSplitScreenTab,
|
||||
reorderTabs,
|
||||
} = useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number;
|
||||
@@ -48,6 +49,7 @@ export function TopNavbar({
|
||||
setSplitScreenTab: (id: number) => void;
|
||||
removeTab: (id: number) => void;
|
||||
allSplitScreenTab: number[];
|
||||
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||
};
|
||||
const leftPosition = state === "collapsed" ? "26px" : "264px";
|
||||
const { t } = useTranslation();
|
||||
@@ -56,6 +58,8 @@ export function TopNavbar({
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false);
|
||||
const [draggedTabIndex, setDraggedTabIndex] = useState<number | null>(null);
|
||||
const [dragOverTabIndex, setDragOverTabIndex] = useState<number | null>(null);
|
||||
|
||||
const handleTabActivate = (tabId: number) => {
|
||||
setCurrentTab(tabId);
|
||||
@@ -234,6 +238,35 @@ export function TopNavbar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedTabIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedTabIndex !== null && draggedTabIndex !== index) {
|
||||
setDragOverTabIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverTabIndex(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedTabIndex !== null && draggedTabIndex !== dropIndex) {
|
||||
reorderTabs(draggedTabIndex, dropIndex);
|
||||
}
|
||||
setDraggedTabIndex(null);
|
||||
setDragOverTabIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedTabIndex(null);
|
||||
setDragOverTabIndex(null);
|
||||
};
|
||||
|
||||
const isSplitScreenActive =
|
||||
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||
const currentTabObj = tabs.find((t: TabData) => t.id === currentTab);
|
||||
@@ -258,8 +291,13 @@ export function TopNavbar({
|
||||
right: "17px",
|
||||
}}
|
||||
>
|
||||
<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-2 thin-scrollbar">
|
||||
{tabs.map((tab: TabData) => {
|
||||
<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">
|
||||
{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 isSplit =
|
||||
Array.isArray(allSplitScreenTab) &&
|
||||
@@ -290,39 +328,99 @@ export function TopNavbar({
|
||||
tab.type === "user_profile") &&
|
||||
isSplitScreenActive);
|
||||
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 showPreviewAfter =
|
||||
draggedTabIndex !== null &&
|
||||
dragOverTabIndex === index &&
|
||||
draggedTabIndex < index;
|
||||
|
||||
const draggedTab =
|
||||
draggedTabIndex !== null ? tabs[draggedTabIndex] : null;
|
||||
|
||||
return (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
tabType={tab.type}
|
||||
title={tab.title}
|
||||
isActive={isActive}
|
||||
onActivate={() => handleTabActivate(tab.id)}
|
||||
onClose={
|
||||
isTerminal ||
|
||||
isServer ||
|
||||
isFileManager ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
? () => handleTabClose(tab.id)
|
||||
: undefined
|
||||
}
|
||||
onSplit={
|
||||
isSplittable ? () => handleTabSplit(tab.id) : undefined
|
||||
}
|
||||
canSplit={isSplittable}
|
||||
canClose={
|
||||
isTerminal ||
|
||||
isServer ||
|
||||
isFileManager ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
}
|
||||
disableActivate={disableActivate}
|
||||
disableSplit={disableSplit}
|
||||
disableClose={disableClose}
|
||||
/>
|
||||
<React.Fragment key={tab.id}>
|
||||
{/* Preview tab before current position */}
|
||||
{showPreviewBefore && 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tab
|
||||
tabType={tab.type}
|
||||
title={tab.title}
|
||||
isActive={isActive}
|
||||
onActivate={() => handleTabActivate(tab.id)}
|
||||
onClose={
|
||||
isTerminal ||
|
||||
isServer ||
|
||||
isFileManager ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
? () => handleTabClose(tab.id)
|
||||
: undefined
|
||||
}
|
||||
onSplit={
|
||||
isSplittable ? () => handleTabSplit(tab.id) : undefined
|
||||
}
|
||||
canSplit={isSplittable}
|
||||
canClose={
|
||||
isTerminal ||
|
||||
isServer ||
|
||||
isFileManager ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
}
|
||||
disableActivate={disableActivate}
|
||||
disableSplit={disableSplit}
|
||||
disableClose={disableClose}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
isDragging={isDragging}
|
||||
isDragOver={false}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
fileLogger,
|
||||
statsLogger,
|
||||
systemLogger,
|
||||
homepageLogger,
|
||||
type LogContext,
|
||||
} from "../lib/frontend-logger.js";
|
||||
|
||||
@@ -121,6 +122,11 @@ function getLoggerForService(serviceName: string) {
|
||||
return statsLogger;
|
||||
} else if (serviceName.includes("AUTH") || serviceName.includes("auth")) {
|
||||
return authLogger;
|
||||
} else if (
|
||||
serviceName.includes("HOMEPAGE") ||
|
||||
serviceName.includes("homepage")
|
||||
) {
|
||||
return homepageLogger;
|
||||
} else {
|
||||
return apiLogger;
|
||||
}
|
||||
@@ -484,6 +490,9 @@ function initializeApiInstances() {
|
||||
|
||||
// Authentication API (port 30001)
|
||||
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
||||
|
||||
// Homepage API (port 30006)
|
||||
homepageApi = createApiInstance(getApiUrl("", 30006), "HOMEPAGE");
|
||||
}
|
||||
|
||||
// SSH Host Management API (port 30001)
|
||||
@@ -501,6 +510,9 @@ export let statsApi: AxiosInstance;
|
||||
// Authentication API (port 30001)
|
||||
export let authApi: AxiosInstance;
|
||||
|
||||
// Homepage API (port 30006)
|
||||
export let homepageApi: AxiosInstance;
|
||||
|
||||
if (isElectron()) {
|
||||
getServerConfig()
|
||||
.then((config) => {
|
||||
@@ -2353,3 +2365,70 @@ export async function deleteSnippet(
|
||||
throw handleApiError(error, "delete snippet");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOMEPAGE API
|
||||
// ============================================================================
|
||||
|
||||
export interface UptimeInfo {
|
||||
uptimeMs: number;
|
||||
uptimeSeconds: number;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export interface RecentActivityItem {
|
||||
id: number;
|
||||
userId: string;
|
||||
type: "terminal" | "file_manager";
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export async function getUptime(): Promise<UptimeInfo> {
|
||||
try {
|
||||
const response = await homepageApi.get("/uptime");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch uptime");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentActivity(
|
||||
limit?: number,
|
||||
): Promise<RecentActivityItem[]> {
|
||||
try {
|
||||
const response = await homepageApi.get("/activity/recent", {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch recent activity");
|
||||
}
|
||||
}
|
||||
|
||||
export async function logActivity(
|
||||
type: "terminal" | "file_manager",
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
): Promise<{ message: string; id: number | string }> {
|
||||
try {
|
||||
const response = await homepageApi.post("/activity/log", {
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "log activity");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetRecentActivity(): Promise<{ message: string }> {
|
||||
try {
|
||||
const response = await homepageApi.delete("/activity/reset");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reset recent activity");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user