feat: Connect dashboard to backend and update tab system to be similar to a browser (neither are fully finished)

This commit is contained in:
LukeGus
2025-10-18 02:54:29 -05:00
parent 3901bc9899
commit a44e2be8a4
16 changed files with 1151 additions and 186 deletions

View File

@@ -82,7 +82,7 @@ COPY --chown=node:node package.json ./
VOLUME ["/app/data"] VOLUME ["/app/data"]
EXPOSE ${PORT} 30001 30002 30003 30004 30005 EXPOSE ${PORT} 30001 30002 30003 30004 30005 300006
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh

View File

@@ -259,6 +259,24 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; 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; error_page 500 502 503 504 /50x.html;
location = /50x.html { location = /50x.html {
root /usr/share/nginx/html; root /usr/share/nginx/html;

View File

@@ -248,6 +248,24 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; 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; error_page 500 502 503 504 /50x.html;
location = /50x.html { location = /50x.html {
root /usr/share/nginx/html; root /usr/share/nginx/html;

View File

@@ -189,3 +189,18 @@ export const snippets = sqliteTable("snippets", {
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .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
View 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",
});
}
});

View File

@@ -1,9 +1,10 @@
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2"; import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js"; 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 { eq, and } from "drizzle-orm";
import { fileLogger } from "../utils/logger.js"; import { fileLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -101,6 +102,11 @@ interface PendingTOTPSession {
config: import("ssh2").ConnectConfig; config: import("ssh2").ConnectConfig;
createdAt: number; createdAt: number;
sessionId: string; sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
} }
const sshSessions: Record<string, SSHSession> = {}; const sshSessions: Record<string, SSHSession> = {};
@@ -365,6 +371,56 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}; };
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
res.json({ status: "success", message: "SSH connection established" }); 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) => { client.on("error", (err) => {
@@ -435,6 +491,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
config, config,
createdAt: Date.now(), createdAt: Date.now(),
sessionId, sessionId,
hostId,
ip,
port,
username,
userId,
}; };
fileLogger.info("Created TOTP session", { fileLogger.info("Created TOTP session", {
@@ -548,6 +609,61 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
status: "success", status: "success",
message: "TOTP verified, SSH connection established", 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) => { session.client.on("error", (err) => {

View File

@@ -6,8 +6,9 @@ import {
type ConnectConfig, type ConnectConfig,
} from "ssh2"; } from "ssh2";
import { parse as parseUrl } from "url"; import { parse as parseUrl } from "url";
import axios from "axios";
import { getDb } from "../database/db/index.js"; 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 { eq, and } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js"; import { sshLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -565,6 +566,62 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send( ws.send(
JSON.stringify({ type: "connected", message: "SSH connected" }), 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",
});
}
})();
}
}, },
); );
}); });

View File

@@ -104,6 +104,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
await import("./ssh/tunnel.js"); await import("./ssh/tunnel.js");
await import("./ssh/file-manager.js"); await import("./ssh/file-manager.js");
await import("./ssh/server-stats.js"); await import("./ssh/server-stats.js");
await import("./homepage.js");
process.on("SIGINT", () => { process.on("SIGINT", () => {
systemLogger.info( systemLogger.info(

View File

@@ -253,5 +253,6 @@ export const apiLogger = new Logger("API", "🌐", "#3b82f6");
export const authLogger = new Logger("AUTH", "🔐", "#ef4444"); export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6"); export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6"); export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#ec4899");
export const logger = systemLogger; export const logger = systemLogger;

View File

@@ -2,7 +2,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js"; import { DataCrypto } from "./data-crypto.js";
import type { SQLiteTable } from "drizzle-orm/sqlite-core"; 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 { class SimpleDBOps {
static async insert<T extends Record<string, unknown>>( static async insert<T extends Record<string, unknown>>(

View File

@@ -379,5 +379,6 @@ export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a"); export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e"); export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a"); export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
export const homepageLogger = new FrontendLogger("HOMEPAGE", "🏠", "#ec4899");
export const logger = systemLogger; export const logger = systemLogger;

View File

@@ -3,9 +3,23 @@ import { Auth } from "@/ui/Desktop/Authentication/Auth.tsx";
import { UpdateLog } from "@/ui/Desktop/Apps/Dashboard/Apps/UpdateLog.tsx"; import { UpdateLog } from "@/ui/Desktop/Apps/Dashboard/Apps/UpdateLog.tsx";
import { AlertManager } from "@/ui/Desktop/Apps/Dashboard/Apps/Alerts/AlertManager.tsx"; import { AlertManager } from "@/ui/Desktop/Apps/Dashboard/Apps/Alerts/AlertManager.tsx";
import { Button } from "@/components/ui/button.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 { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { import {
ChartLine, ChartLine,
Clock, Clock,
@@ -39,13 +53,33 @@ export function Dashboard({
authLoading, authLoading,
onAuthSuccess, onAuthSuccess,
isTopbarOpen, isTopbarOpen,
onSelectView,
}: DashboardProps): React.ReactElement { }: DashboardProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [, setUsername] = useState<string | null>(null); const [, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = 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"; let sidebarState: "expanded" | "collapsed" = "expanded";
try { try {
const sidebar = useSidebar(); const sidebar = useSidebar();
@@ -99,6 +133,110 @@ export function Dashboard({
} }
}, [isAuthenticated]); }, [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 ( return (
<> <>
{!loggedIn ? ( {!loggedIn ? (
@@ -201,14 +339,16 @@ export function Dashboard({
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground"> <p className="leading-none text-muted-foreground">
v1.8.0 {versionText}
</p> </p>
<Button <Button
variant="outline" variant="outline"
size="sm" 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> </Button>
<UpdateLog loggedIn={loggedIn} /> <UpdateLog loggedIn={loggedIn} />
</div> </div>
@@ -226,7 +366,7 @@ export function Dashboard({
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground"> <p className="leading-none text-muted-foreground">
0d 0h 7m {uptime}
</p> </p>
</div> </div>
</div> </div>
@@ -242,38 +382,55 @@ export function Dashboard({
</div> </div>
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground"> <p
healthy className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
>
{dbHealth}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2"> <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"> <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">
<Server <div className="flex flex-row items-center">
size={16} <Server
color="#FFFFFF" size={16}
className="mr-3 shrink-0" color="#FFFFFF"
/> className="mr-3 shrink-0"
<p className="m-0 leading-none">Total Servers</p> />
<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>
<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"> <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">
<Network <div className="flex flex-row items-center">
size={16} <Network
color="#FFFFFF" size={16}
className="mr-3 shrink-0" color="#FFFFFF"
/> className="mr-3 shrink-0"
<p className="m-0 leading-none">Total Tunnels</p> />
<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> </div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2"> <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"> <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">
<Key <div className="flex flex-row items-center">
size={16} <Key
color="#FFFFFF" size={16}
className="mr-3 shrink-0" color="#FFFFFF"
/> className="mr-3 shrink-0"
<p className="m-0 leading-none">Total Credentials</p> />
<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> </div>
</div> </div>
@@ -289,18 +446,31 @@ export function Dashboard({
variant="outline" variant="outline"
size="sm" size="sm"
className="border-2 !border-dark-border h-7" className="border-2 !border-dark-border h-7"
onClick={handleResetActivity}
> >
Reset Reset
</Button> </Button>
</div> </div>
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden"> <div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden">
<Button {recentActivity.length === 0 ? (
variant="outline" <p className="text-muted-foreground text-sm">
className="border-2 !border-dark-border bg-dark-bg" No recent activity
> </p>
<Server size={20} className="shrink-0" /> ) : (
<p className="truncate ml-2 font-semibold">Server #1</p> recentActivity.map((item) => (
</Button> <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> </div>
</div> </div>
@@ -316,6 +486,7 @@ export function Dashboard({
<Button <Button
variant="outline" variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3" className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
onClick={() => onSelectView("host-manager-add")}
> >
<Server <Server
className="shrink-0" className="shrink-0"
@@ -328,6 +499,7 @@ export function Dashboard({
<Button <Button
variant="outline" variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3" className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
onClick={() => onSelectView("host-manager-credentials")}
> >
<Key <Key
className="shrink-0" className="shrink-0"
@@ -337,21 +509,25 @@ export function Dashboard({
Add Credential Add Credential
</span> </span>
</Button> </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 <Button
variant="outline" variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3" className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
> onClick={() => onSelectView("user-profile")}
<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"
> >
<User <User
className="shrink-0" className="shrink-0"
@@ -371,23 +547,42 @@ export function Dashboard({
Server Stats Server Stats
</p> </p>
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden"> <div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden">
<Button {serverStats.length === 0 ? (
variant="outline" <p className="text-muted-foreground text-sm">
className="border-2 !border-dark-border bg-dark-bg h-auto p-3" No server data available
> </p>
<div className="flex flex-col w-full"> ) : (
<div className="flex flex-row items-center mb-2"> serverStats.map((server) => (
<Server size={20} className="shrink-0" /> <Button
<p className="truncate ml-2 font-semibold"> key={server.id}
Server #1 variant="outline"
</p> className="border-2 !border-dark-border bg-dark-bg h-auto p-3"
</div> >
<div className="flex flex-row justify-between text-xs text-muted-foreground"> <div className="flex flex-col w-full">
<span>CPU: 45%</span> <div className="flex flex-row items-center mb-2">
<span>RAM: 62%</span> <Server size={20} className="shrink-0" />
</div> <p className="truncate ml-2 font-semibold">
</div> {server.name}
</Button> </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> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { ButtonGroup } from "@/components/ui/button-group.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { import {
Home, Home,
SeparatorVertical, SeparatorVertical,
@@ -24,6 +24,13 @@ interface TabProps {
disableActivate?: boolean; disableActivate?: boolean;
disableSplit?: boolean; disableSplit?: boolean;
disableClose?: boolean; disableClose?: boolean;
onDragStart?: () => void;
onDragOver?: (e: React.DragEvent) => void;
onDragLeave?: () => void;
onDrop?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
isDragging?: boolean;
isDragOver?: boolean;
} }
export function Tab({ export function Tab({
@@ -38,18 +45,56 @@ export function Tab({
disableActivate = false, disableActivate = false,
disableSplit = false, disableSplit = false,
disableClose = false, disableClose = false,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
isDragging = false,
isDragOver = false,
}: TabProps): React.ReactElement { }: TabProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const dragProps = {
draggable: true,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
};
// Firefox-style tab classes using cn utility
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") { if (tabType === "home") {
return ( return (
<Button <div
variant="outline" className={tabBaseClasses}
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`} {...dragProps}
onClick={onActivate} onClick={!disableActivate ? onActivate : undefined}
disabled={disableActivate} style={{
marginBottom: "-2px",
borderBottom: isActive ? "2px solid transparent" : "none",
}}
> >
<Home /> <Home className="h-4 w-4" />
</Button> </div>
); );
} }
@@ -62,102 +107,147 @@ export function Tab({
const isServer = tabType === "server"; const isServer = tabType === "server";
const isFileManager = tabType === "file_manager"; const isFileManager = tabType === "file_manager";
const isUserProfile = tabType === "user_profile"; const isUserProfile = tabType === "user_profile";
const displayTitle =
title ||
(isServer
? t("nav.serverStats")
: isFileManager
? t("nav.fileManager")
: isUserProfile
? t("nav.userProfile")
: t("nav.terminal"));
return ( return (
<ButtonGroup> <div
<Button className={tabBaseClasses}
variant="outline" {...dragProps}
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`} style={{
onClick={onActivate} marginBottom: "-2px",
disabled={disableActivate} borderBottom: isActive ? "2px solid transparent" : "none",
}}
>
<div
className="flex items-center gap-1.5 flex-1 min-w-0"
onClick={!disableActivate ? onActivate : undefined}
> >
{isServer ? ( {isServer ? (
<ServerIcon className="mr-1 h-4 w-4" /> <ServerIcon className="h-4 w-4 flex-shrink-0" />
) : isFileManager ? ( ) : isFileManager ? (
<FolderIcon className="mr-1 h-4 w-4" /> <FolderIcon className="h-4 w-4 flex-shrink-0" />
) : isUserProfile ? ( ) : 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 || <span className="truncate text-sm">{displayTitle}</span>
(isServer </div>
? t("nav.serverStats")
: isFileManager
? t("nav.fileManager")
: isUserProfile
? t("nav.userProfile")
: t("nav.terminal"))}
</Button>
{canSplit && ( {canSplit && (
<Button <Button
variant="outline" variant="ghost"
className="!px-2 border-1 border-dark-border" size="icon"
onClick={onSplit} className={cn("h-6 w-6", disableSplit && "opacity-50")}
onClick={(e) => {
e.stopPropagation();
if (!disableSplit && onSplit) onSplit();
}}
disabled={disableSplit} disabled={disableSplit}
title={ title={
disableSplit ? t("nav.cannotSplitTab") : t("nav.splitScreen") disableSplit ? t("nav.cannotSplitTab") : t("nav.splitScreen")
} }
> >
<SeparatorVertical className="w-[28px] h-[28px]" /> <SeparatorVertical className="h-4 w-4" />
</Button> </Button>
)} )}
{canClose && ( {canClose && (
<Button <Button
variant="outline" variant="ghost"
className="!px-2 border-1 border-dark-border" size="icon"
onClick={onClose} className={cn("h-6 w-6", disableClose && "opacity-50")}
onClick={(e) => {
e.stopPropagation();
if (!disableClose && onClose) onClose();
}}
disabled={disableClose} disabled={disableClose}
> >
<X /> <X className="h-4 w-4" />
</Button> </Button>
)} )}
</ButtonGroup> </div>
); );
} }
if (tabType === "ssh_manager") { if (tabType === "ssh_manager") {
return ( return (
<ButtonGroup> <div
<Button className={tabBaseClasses}
variant="outline" {...dragProps}
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`} style={{
onClick={onActivate} marginBottom: "-2px",
disabled={disableActivate} 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")} <span className="truncate text-sm">
</Button> {title || t("nav.sshManager")}
<Button </span>
variant="outline" </div>
className="!px-2 border-1 border-dark-border"
onClick={onClose} {canClose && (
disabled={disableClose} <Button
> variant="ghost"
<X /> size="icon"
</Button> className={cn("h-6 w-6", disableClose && "opacity-50")}
</ButtonGroup> onClick={(e) => {
e.stopPropagation();
if (!disableClose && onClose) onClose();
}}
disabled={disableClose}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
); );
} }
if (tabType === "admin") { if (tabType === "admin") {
return ( return (
<ButtonGroup> <div
<Button className={tabBaseClasses}
variant="outline" {...dragProps}
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`} style={{
onClick={onActivate} marginBottom: "-2px",
disabled={disableActivate} 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")} <span className="truncate text-sm">{title || t("nav.admin")}</span>
</Button> </div>
<Button
variant="outline" {canClose && (
className="!px-2 border-1 border-dark-border" <Button
onClick={onClose} variant="ghost"
disabled={disableClose} size="icon"
> className={cn("h-6 w-6", disableClose && "opacity-50")}
<X /> onClick={(e) => {
</Button> e.stopPropagation();
</ButtonGroup> if (!disableClose && onClose) onClose();
}}
disabled={disableClose}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
); );
} }

View File

@@ -20,6 +20,7 @@ interface TabContextType {
setCurrentTab: (tabId: number) => void; setCurrentTab: (tabId: number) => void;
setSplitScreenTab: (tabId: number) => void; setSplitScreenTab: (tabId: number) => void;
getTab: (tabId: number) => Tab | undefined; getTab: (tabId: number) => Tab | undefined;
reorderTabs: (fromIndex: number, toIndex: number) => void;
updateHostConfig: ( updateHostConfig: (
hostId: number, hostId: number,
newHostConfig: { newHostConfig: {
@@ -152,6 +153,15 @@ export function TabProvider({ children }: TabProviderProps) {
return tabs.find((tab) => tab.id === tabId); 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 = ( const updateHostConfig = (
hostId: number, hostId: number,
newHostConfig: { newHostConfig: {
@@ -187,6 +197,7 @@ export function TabProvider({ children }: TabProviderProps) {
setCurrentTab, setCurrentTab,
setSplitScreenTab, setSplitScreenTab,
getTab, getTab,
reorderTabs,
updateHostConfig, updateHostConfig,
}; };

View File

@@ -41,6 +41,7 @@ export function TopNavbar({
setSplitScreenTab, setSplitScreenTab,
removeTab, removeTab,
allSplitScreenTab, allSplitScreenTab,
reorderTabs,
} = useTabs() as { } = useTabs() as {
tabs: TabData[]; tabs: TabData[];
currentTab: number; currentTab: number;
@@ -48,6 +49,7 @@ export function TopNavbar({
setSplitScreenTab: (id: number) => void; setSplitScreenTab: (id: number) => void;
removeTab: (id: number) => void; removeTab: (id: number) => void;
allSplitScreenTab: number[]; allSplitScreenTab: number[];
reorderTabs: (fromIndex: number, toIndex: number) => void;
}; };
const leftPosition = state === "collapsed" ? "26px" : "264px"; const leftPosition = state === "collapsed" ? "26px" : "264px";
const { t } = useTranslation(); const { t } = useTranslation();
@@ -56,6 +58,8 @@ export function TopNavbar({
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]); const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false); const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false);
const [draggedTabIndex, setDraggedTabIndex] = useState<number | null>(null);
const [dragOverTabIndex, setDragOverTabIndex] = useState<number | null>(null);
const handleTabActivate = (tabId: number) => { const handleTabActivate = (tabId: number) => {
setCurrentTab(tabId); 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 = const isSplitScreenActive =
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const currentTabObj = tabs.find((t: TabData) => t.id === currentTab); const currentTabObj = tabs.find((t: TabData) => t.id === currentTab);
@@ -258,8 +291,13 @@ export function TopNavbar({
right: "17px", 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"> <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) => { {tabs.map((tab: TabData, index: number) => {
// Insert preview tab before this position if dragging over it
const showPreviewBefore =
draggedTabIndex !== null &&
dragOverTabIndex === index &&
draggedTabIndex > index;
const isActive = tab.id === currentTab; const isActive = tab.id === currentTab;
const isSplit = const isSplit =
Array.isArray(allSplitScreenTab) && Array.isArray(allSplitScreenTab) &&
@@ -290,39 +328,99 @@ export function TopNavbar({
tab.type === "user_profile") && tab.type === "user_profile") &&
isSplitScreenActive); isSplitScreenActive);
const disableClose = (isSplitScreenActive && isActive) || isSplit; const disableClose = (isSplitScreenActive && isActive) || isSplit;
const isDragging = draggedTabIndex === index;
const isDragOver = dragOverTabIndex === index;
// Show preview after this position if dragging over and coming from before
const showPreviewAfter =
draggedTabIndex !== null &&
dragOverTabIndex === index &&
draggedTabIndex < index;
const draggedTab =
draggedTabIndex !== null ? tabs[draggedTabIndex] : null;
return ( return (
<Tab <React.Fragment key={tab.id}>
key={tab.id} {/* Preview tab before current position */}
tabType={tab.type} {showPreviewBefore && draggedTab && (
title={tab.title} <Tab
isActive={isActive} tabType={draggedTab.type}
onActivate={() => handleTabActivate(tab.id)} title={draggedTab.title}
onClose={ isActive={false}
isTerminal || canSplit={
isServer || draggedTab.type === "terminal" ||
isFileManager || draggedTab.type === "server" ||
isSshManager || draggedTab.type === "file_manager"
isAdmin || }
isUserProfile canClose={true}
? () => handleTabClose(tab.id) disableActivate={true}
: undefined disableSplit={true}
} disableClose={true}
onSplit={ isDragging={false}
isSplittable ? () => handleTabSplit(tab.id) : undefined isDragOver={true}
} />
canSplit={isSplittable} )}
canClose={
isTerminal || <Tab
isServer || tabType={tab.type}
isFileManager || title={tab.title}
isSshManager || isActive={isActive}
isAdmin || onActivate={() => handleTabActivate(tab.id)}
isUserProfile onClose={
} isTerminal ||
disableActivate={disableActivate} isServer ||
disableSplit={disableSplit} isFileManager ||
disableClose={disableClose} 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> </div>

View File

@@ -15,6 +15,7 @@ import {
fileLogger, fileLogger,
statsLogger, statsLogger,
systemLogger, systemLogger,
homepageLogger,
type LogContext, type LogContext,
} from "../lib/frontend-logger.js"; } from "../lib/frontend-logger.js";
@@ -121,6 +122,11 @@ function getLoggerForService(serviceName: string) {
return statsLogger; return statsLogger;
} else if (serviceName.includes("AUTH") || serviceName.includes("auth")) { } else if (serviceName.includes("AUTH") || serviceName.includes("auth")) {
return authLogger; return authLogger;
} else if (
serviceName.includes("HOMEPAGE") ||
serviceName.includes("homepage")
) {
return homepageLogger;
} else { } else {
return apiLogger; return apiLogger;
} }
@@ -484,6 +490,9 @@ function initializeApiInstances() {
// Authentication API (port 30001) // Authentication API (port 30001)
authApi = createApiInstance(getApiUrl("", 30001), "AUTH"); authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
// Homepage API (port 30006)
homepageApi = createApiInstance(getApiUrl("", 30006), "HOMEPAGE");
} }
// SSH Host Management API (port 30001) // SSH Host Management API (port 30001)
@@ -501,6 +510,9 @@ export let statsApi: AxiosInstance;
// Authentication API (port 30001) // Authentication API (port 30001)
export let authApi: AxiosInstance; export let authApi: AxiosInstance;
// Homepage API (port 30006)
export let homepageApi: AxiosInstance;
if (isElectron()) { if (isElectron()) {
getServerConfig() getServerConfig()
.then((config) => { .then((config) => {
@@ -2353,3 +2365,70 @@ export async function deleteSnippet(
throw handleApiError(error, "delete snippet"); 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");
}
}