feat: Connect dashboard to backend and update tab system to be similar to a browser (neither are fully finished)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
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 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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>>(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user