diff --git a/docker/Dockerfile b/docker/Dockerfile index 8bf66283..65fdce1b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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"] \ No newline at end of file +CMD ["/entrypoint.sh"] diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index f64e8e4e..53caa38b 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -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; } } -} \ No newline at end of file +} diff --git a/docker/nginx.conf b/docker/nginx.conf index c180c180..5dde75d3 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -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; diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index b3c09b6f..46cbb7f9 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -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`), +}); diff --git a/src/backend/homepage.ts b/src/backend/homepage.ts new file mode 100644 index 00000000..e36e8804 --- /dev/null +++ b/src/backend/homepage.ts @@ -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", + }); + } +}); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 939b283a..b3bb9b3d 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -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 = {}; @@ -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) => { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index c276a2ce..303d6344 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -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", + }); + } + })(); + } }, ); }); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 7ee3c1b3..ad8b3120 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -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( diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index 1d8b340e..3f986399 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -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; diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index 311cfec9..e2764dca 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -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>( diff --git a/src/lib/frontend-logger.ts b/src/lib/frontend-logger.ts index 0eec0783..367c4060 100644 --- a/src/lib/frontend-logger.ts +++ b/src/lib/frontend-logger.ts @@ -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; diff --git a/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx b/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx index 5a975855..d1c343ef 100644 --- a/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx +++ b/src/ui/Desktop/Apps/Dashboard/Dashboard.tsx @@ -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(null); const [userId, setUserId] = useState(null); const [dbError, setDbError] = useState(null); + // Dashboard data state + const [uptime, setUptime] = useState("0d 0h 0m"); + const [versionStatus, setVersionStatus] = useState< + "up_to_date" | "requires_update" + >("up_to_date"); + const [versionText, setVersionText] = useState("v1.8.0"); + const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy"); + const [totalServers, setTotalServers] = useState(0); + const [totalTunnels, setTotalTunnels] = useState(0); + const [totalCredentials, setTotalCredentials] = useState(0); + const [recentActivity, setRecentActivity] = useState( + [], + ); + 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({

- v1.8.0 + {versionText}

@@ -226,7 +366,7 @@ export function Dashboard({

- 0d 0h 7m + {uptime}

@@ -242,38 +382,55 @@ export function Dashboard({
-

- healthy +

+ {dbHealth}

-
- -

Total Servers

+
+
+ +

Total Servers

+
+

+ {totalServers} +

-
- -

Total Tunnels

+
+
+ +

Total Tunnels

+
+

+ {totalTunnels} +

-
- -

Total Credentials

+
+
+ +

Total Credentials

+
+

+ {totalCredentials} +

@@ -289,18 +446,31 @@ export function Dashboard({ variant="outline" size="sm" className="border-2 !border-dark-border h-7" + onClick={handleResetActivity} > Reset
- + {recentActivity.length === 0 ? ( +

+ No recent activity +

+ ) : ( + recentActivity.map((item) => ( + + )) + )}
@@ -316,6 +486,7 @@ export function Dashboard({ + {isAdmin && ( + + )} - + {serverStats.length === 0 ? ( +

+ No server data available +

+ ) : ( + serverStats.map((server) => ( + + )) + )} diff --git a/src/ui/Desktop/Navigation/Tabs/Tab.tsx b/src/ui/Desktop/Navigation/Tabs/Tab.tsx index a87f5943..46c9f4ea 100644 --- a/src/ui/Desktop/Navigation/Tabs/Tab.tsx +++ b/src/ui/Desktop/Navigation/Tabs/Tab.tsx @@ -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 ( - + + ); } @@ -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 ( - - + {displayTitle} + + {canSplit && ( )} + {canClose && ( )} - + ); } if (tabType === "ssh_manager") { return ( - - - - + + {title || t("nav.sshManager")} + + + + {canClose && ( + + )} + ); } if (tabType === "admin") { return ( - - - - + {title || t("nav.admin")} + + + {canClose && ( + + )} + ); } diff --git a/src/ui/Desktop/Navigation/Tabs/TabContext.tsx b/src/ui/Desktop/Navigation/Tabs/TabContext.tsx index fbf7673f..0b2fd754 100644 --- a/src/ui/Desktop/Navigation/Tabs/TabContext.tsx +++ b/src/ui/Desktop/Navigation/Tabs/TabContext.tsx @@ -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, }; diff --git a/src/ui/Desktop/Navigation/TopNavbar.tsx b/src/ui/Desktop/Navigation/TopNavbar.tsx index 4356f5f6..e228e1c5 100644 --- a/src/ui/Desktop/Navigation/TopNavbar.tsx +++ b/src/ui/Desktop/Navigation/TopNavbar.tsx @@ -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([]); const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false); + const [draggedTabIndex, setDraggedTabIndex] = useState(null); + const [dragOverTabIndex, setDragOverTabIndex] = useState(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", }} > -
- {tabs.map((tab: TabData) => { +
+ {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 ( - 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} - /> + + {/* Preview tab before current position */} + {showPreviewBefore && draggedTab && ( + + )} + + 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 && ( + + )} + ); })}
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 5f38cac9..95dc7d6b 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -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 { + try { + const response = await homepageApi.get("/uptime"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch uptime"); + } +} + +export async function getRecentActivity( + limit?: number, +): Promise { + 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"); + } +}