fix: general server stats issues, file manager decoding, ui qol
This commit is contained in:
@@ -2,8 +2,8 @@ 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 { recentActivity, sshData, hostAccess } from "./database/db/schema.js";
|
||||
import { eq, and, desc, or } from "drizzle-orm";
|
||||
import { dashboardLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
@@ -164,7 +164,7 @@ app.post("/activity/log", async (req, res) => {
|
||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||
}
|
||||
|
||||
const hosts = await SimpleDBOps.select(
|
||||
const ownedHosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
@@ -173,8 +173,19 @@ app.post("/activity/log", async (req, res) => {
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
if (ownedHosts.length === 0) {
|
||||
const sharedHosts = await getDb()
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
|
||||
);
|
||||
|
||||
if (sharedHosts.length === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Host not found or access denied" });
|
||||
}
|
||||
}
|
||||
|
||||
const result = (await SimpleDBOps.insert(
|
||||
|
||||
@@ -1440,7 +1440,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
let fileBuffer;
|
||||
try {
|
||||
if (typeof content === "string") {
|
||||
fileBuffer = Buffer.from(content, "utf8");
|
||||
fileBuffer = Buffer.from(content, "base64");
|
||||
} else if (Buffer.isBuffer(content)) {
|
||||
fileBuffer = content;
|
||||
} else {
|
||||
@@ -1649,7 +1649,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
let fileBuffer;
|
||||
try {
|
||||
if (typeof content === "string") {
|
||||
fileBuffer = Buffer.from(content, "utf8");
|
||||
fileBuffer = Buffer.from(content, "base64");
|
||||
} else if (Buffer.isBuffer(content)) {
|
||||
fileBuffer = content;
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { statsLogger, sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js";
|
||||
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
|
||||
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
|
||||
@@ -218,6 +219,13 @@ interface PendingTOTPSession {
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
interface MetricsViewer {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
lastHeartbeat: number;
|
||||
}
|
||||
|
||||
const metricsSessions: Record<string, MetricsSession> = {};
|
||||
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
|
||||
@@ -868,6 +876,7 @@ const metricsCache = new MetricsCache();
|
||||
const authFailureTracker = new AuthFailureTracker();
|
||||
const pollingBackoff = new PollingBackoff();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
|
||||
type HostStatus = "online" | "offline";
|
||||
|
||||
@@ -931,6 +940,7 @@ interface HostPollingConfig {
|
||||
statsConfig: StatsConfig;
|
||||
statusTimer?: NodeJS.Timeout;
|
||||
metricsTimer?: NodeJS.Timeout;
|
||||
viewerUserId?: string;
|
||||
}
|
||||
|
||||
class PollingManager {
|
||||
@@ -943,6 +953,15 @@ class PollingManager {
|
||||
timestamp: number;
|
||||
}
|
||||
>();
|
||||
private activeViewers = new Map<number, Set<string>>();
|
||||
private viewerDetails = new Map<string, MetricsViewer>();
|
||||
private viewerCleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor() {
|
||||
this.viewerCleanupInterval = setInterval(() => {
|
||||
this.cleanupInactiveViewers();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig {
|
||||
if (!statsConfigStr) {
|
||||
@@ -981,10 +1000,11 @@ class PollingManager {
|
||||
|
||||
async startPollingForHost(
|
||||
host: SSHHostWithCredentials,
|
||||
options?: { statusOnly?: boolean },
|
||||
options?: { statusOnly?: boolean; viewerUserId?: string },
|
||||
): Promise<void> {
|
||||
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
||||
const statusOnly = options?.statusOnly ?? false;
|
||||
const viewerUserId = options?.viewerUserId;
|
||||
|
||||
const existingConfig = this.pollingConfigs.get(host.id);
|
||||
|
||||
@@ -1009,17 +1029,18 @@ class PollingManager {
|
||||
const config: HostPollingConfig = {
|
||||
host,
|
||||
statsConfig,
|
||||
viewerUserId,
|
||||
};
|
||||
|
||||
if (statsConfig.statusCheckEnabled) {
|
||||
const intervalMs = statsConfig.statusCheckInterval * 1000;
|
||||
|
||||
this.pollHostStatus(host);
|
||||
this.pollHostStatus(host, viewerUserId);
|
||||
|
||||
config.statusTimer = setInterval(() => {
|
||||
const latestConfig = this.pollingConfigs.get(host.id);
|
||||
if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
|
||||
this.pollHostStatus(latestConfig.host);
|
||||
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
|
||||
}
|
||||
}, intervalMs);
|
||||
} else {
|
||||
@@ -1029,12 +1050,12 @@ class PollingManager {
|
||||
if (!statusOnly && statsConfig.metricsEnabled) {
|
||||
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||
|
||||
await this.pollHostMetrics(host);
|
||||
await this.pollHostMetrics(host, viewerUserId);
|
||||
|
||||
config.metricsTimer = setInterval(() => {
|
||||
const latestConfig = this.pollingConfigs.get(host.id);
|
||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||
this.pollHostMetrics(latestConfig.host);
|
||||
this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId);
|
||||
}
|
||||
}, intervalMs);
|
||||
} else {
|
||||
@@ -1044,13 +1065,13 @@ class PollingManager {
|
||||
this.pollingConfigs.set(host.id, config);
|
||||
}
|
||||
|
||||
private async pollHostStatus(host: SSHHostWithCredentials): Promise<void> {
|
||||
const refreshedHost = await fetchHostById(host.id, host.userId);
|
||||
private async pollHostStatus(
|
||||
host: SSHHostWithCredentials,
|
||||
viewerUserId?: string,
|
||||
): Promise<void> {
|
||||
const userId = viewerUserId || host.userId;
|
||||
const refreshedHost = await fetchHostById(host.id, userId);
|
||||
if (!refreshedHost) {
|
||||
statsLogger.warn("Host not found during status polling", {
|
||||
operation: "poll_host_status",
|
||||
hostId: host.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1074,13 +1095,13 @@ class PollingManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
|
||||
const refreshedHost = await fetchHostById(host.id, host.userId);
|
||||
private async pollHostMetrics(
|
||||
host: SSHHostWithCredentials,
|
||||
viewerUserId?: string,
|
||||
): Promise<void> {
|
||||
const userId = viewerUserId || host.userId;
|
||||
const refreshedHost = await fetchHostById(host.id, userId);
|
||||
if (!refreshedHost) {
|
||||
statsLogger.warn("Host not found during metrics polling", {
|
||||
operation: "poll_host_metrics",
|
||||
hostId: host.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1194,7 +1215,87 @@ class PollingManager {
|
||||
}
|
||||
}
|
||||
|
||||
registerViewer(hostId: number, sessionId: string, userId: string): void {
|
||||
if (!this.activeViewers.has(hostId)) {
|
||||
this.activeViewers.set(hostId, new Set());
|
||||
}
|
||||
this.activeViewers.get(hostId)!.add(sessionId);
|
||||
|
||||
this.viewerDetails.set(sessionId, {
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
lastHeartbeat: Date.now(),
|
||||
});
|
||||
|
||||
if (this.activeViewers.get(hostId)!.size === 1) {
|
||||
this.startMetricsForHost(hostId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
updateHeartbeat(sessionId: string): boolean {
|
||||
const viewer = this.viewerDetails.get(sessionId);
|
||||
if (viewer) {
|
||||
viewer.lastHeartbeat = Date.now();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
unregisterViewer(hostId: number, sessionId: string): void {
|
||||
const viewers = this.activeViewers.get(hostId);
|
||||
if (viewers) {
|
||||
viewers.delete(sessionId);
|
||||
|
||||
if (viewers.size === 0) {
|
||||
this.activeViewers.delete(hostId);
|
||||
this.stopMetricsForHost(hostId);
|
||||
}
|
||||
}
|
||||
this.viewerDetails.delete(sessionId);
|
||||
}
|
||||
|
||||
private async startMetricsForHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (host) {
|
||||
await this.startPollingForHost(host, { viewerUserId: userId });
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to start metrics polling", {
|
||||
operation: "start_metrics_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private stopMetricsForHost(hostId: number): void {
|
||||
this.stopMetricsOnly(hostId);
|
||||
}
|
||||
|
||||
private cleanupInactiveViewers(): void {
|
||||
const now = Date.now();
|
||||
const maxInactivity = 120000;
|
||||
|
||||
for (const [sessionId, viewer] of this.viewerDetails.entries()) {
|
||||
if (now - viewer.lastHeartbeat > maxInactivity) {
|
||||
statsLogger.warn("Cleaning up inactive viewer", {
|
||||
operation: "cleanup_inactive_viewer",
|
||||
sessionId,
|
||||
hostId: viewer.hostId,
|
||||
inactiveFor: Math.floor((now - viewer.lastHeartbeat) / 1000),
|
||||
});
|
||||
this.unregisterViewer(viewer.hostId, sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
clearInterval(this.viewerCleanupInterval);
|
||||
for (const hostId of this.pollingConfigs.keys()) {
|
||||
this.stopPollingForHost(hostId);
|
||||
}
|
||||
@@ -1297,11 +1398,23 @@ async function fetchHostById(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
id,
|
||||
"read",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
statsLogger.warn(`User ${userId} cannot access host ${id}`, {
|
||||
operation: "fetch_host_access_denied",
|
||||
userId,
|
||||
hostId: id,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
|
||||
getDb().select().from(sshData).where(eq(sshData.id, id)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -1362,41 +1475,72 @@ async function resolveHostCredentials(
|
||||
|
||||
if (host.credentialId) {
|
||||
try {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
const ownerId = host.userId;
|
||||
const isSharedHost = userId !== ownerId;
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
baseHost.credentialId = credential.id;
|
||||
baseHost.username = credential.username;
|
||||
baseHost.authType = credential.auth_type || credential.authType;
|
||||
if (isSharedHost) {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
host.id as number,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credential.password) {
|
||||
baseHost.password = credential.password;
|
||||
baseHost.credentialId = host.credentialId;
|
||||
baseHost.authType = sharedCred.authType;
|
||||
|
||||
if (!host.overrideCredentialUsername) {
|
||||
baseHost.username = sharedCred.username;
|
||||
}
|
||||
if (credential.key) {
|
||||
baseHost.key = credential.key;
|
||||
|
||||
if (sharedCred.password) {
|
||||
baseHost.password = sharedCred.password;
|
||||
}
|
||||
if (credential.key_password || credential.keyPassword) {
|
||||
baseHost.keyPassword =
|
||||
credential.key_password || credential.keyPassword;
|
||||
if (sharedCred.key) {
|
||||
baseHost.key = sharedCred.key;
|
||||
}
|
||||
if (credential.key_type || credential.keyType) {
|
||||
baseHost.keyType = credential.key_type || credential.keyType;
|
||||
if (sharedCred.keyPassword) {
|
||||
baseHost.keyPassword = sharedCred.keyPassword;
|
||||
}
|
||||
if (sharedCred.keyType) {
|
||||
baseHost.keyType = sharedCred.keyType;
|
||||
}
|
||||
} else {
|
||||
addLegacyCredentials(baseHost, host);
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, host.credentialId as number)),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
baseHost.credentialId = credential.id;
|
||||
baseHost.authType = credential.auth_type || credential.authType;
|
||||
|
||||
if (!host.overrideCredentialUsername) {
|
||||
baseHost.username = credential.username;
|
||||
}
|
||||
|
||||
if (credential.password) {
|
||||
baseHost.password = credential.password;
|
||||
}
|
||||
if (credential.key) {
|
||||
baseHost.key = credential.key;
|
||||
}
|
||||
if (credential.key_password || credential.keyPassword) {
|
||||
baseHost.keyPassword =
|
||||
credential.key_password || credential.keyPassword;
|
||||
}
|
||||
if (credential.key_type || credential.keyType) {
|
||||
baseHost.keyType = credential.key_type || credential.keyType;
|
||||
}
|
||||
} else {
|
||||
addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.warn(
|
||||
@@ -1928,6 +2072,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
requires_totp?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
viewerSessionId?: string;
|
||||
}>((resolve, reject) => {
|
||||
let isResolved = false;
|
||||
|
||||
@@ -2006,15 +2151,10 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
};
|
||||
scheduleMetricsSessionCleanup(sessionKey);
|
||||
|
||||
pollingManager.startPollingForHost(host).catch((error) => {
|
||||
statsLogger.error("Failed to start polling after connection", {
|
||||
operation: "start_polling_error",
|
||||
hostId: host.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
pollingManager.registerViewer(host.id, viewerSessionId, userId);
|
||||
|
||||
resolve({ success: true });
|
||||
resolve({ success: true, viewerSessionId });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2082,6 +2222,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { viewerSessionId } = req.body;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
@@ -2098,7 +2239,11 @@ app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
|
||||
cleanupMetricsSession(sessionKey);
|
||||
}
|
||||
|
||||
pollingManager.stopMetricsOnly(id);
|
||||
if (viewerSessionId && typeof viewerSessionId === "string") {
|
||||
pollingManager.unregisterViewer(id, viewerSessionId);
|
||||
} else {
|
||||
pollingManager.stopMetricsOnly(id);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -2225,18 +2370,10 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
||||
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
|
||||
const host = await fetchHostById(session.hostId, userId);
|
||||
if (host) {
|
||||
pollingManager.startPollingForHost(host).catch((error) => {
|
||||
statsLogger.error("Failed to start polling after TOTP", {
|
||||
operation: "totp_polling_start_error",
|
||||
hostId: session.hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
pollingManager.registerViewer(session.hostId, viewerSessionId, userId);
|
||||
|
||||
res.json({ success: true });
|
||||
res.json({ success: true, viewerSessionId });
|
||||
} catch (error) {
|
||||
statsLogger.error("TOTP verification failed", {
|
||||
operation: "totp_verification_failed",
|
||||
@@ -2259,6 +2396,101 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/metrics/heartbeat", async (req, res) => {
|
||||
const { viewerSessionId } = req.body;
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
if (!viewerSessionId || typeof viewerSessionId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid viewerSessionId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const success = pollingManager.updateHeartbeat(viewerSessionId);
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: "Viewer session not found" });
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to update heartbeat", {
|
||||
operation: "heartbeat_error",
|
||||
viewerSessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update heartbeat" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/metrics/register-viewer", async (req, res) => {
|
||||
const { hostId } = req.body;
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostId || typeof hostId !== "number") {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
pollingManager.registerViewer(hostId, viewerSessionId, userId);
|
||||
res.json({ success: true, viewerSessionId });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to register viewer", {
|
||||
operation: "register_viewer_error",
|
||||
hostId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to register viewer" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/metrics/unregister-viewer", async (req, res) => {
|
||||
const { hostId, viewerSessionId } = req.body;
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostId || typeof hostId !== "number") {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
|
||||
if (!viewerSessionId || typeof viewerSessionId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid viewerSessionId" });
|
||||
}
|
||||
|
||||
try {
|
||||
pollingManager.unregisterViewer(hostId, viewerSessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to unregister viewer", {
|
||||
operation: "unregister_viewer_error",
|
||||
hostId,
|
||||
viewerSessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unregister viewer" });
|
||||
}
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
pollingManager.destroy();
|
||||
connectionPool.destroy();
|
||||
|
||||
@@ -828,6 +828,9 @@
|
||||
"hostAddedSuccessfully": "Host \"{{name}}\" added successfully!",
|
||||
"hostDeletedSuccessfully": "Host \"{{name}}\" deleted successfully!",
|
||||
"failedToSaveHost": "Failed to save host. Please try again.",
|
||||
"savingHost": "Saving host...",
|
||||
"updatingHost": "Updating host...",
|
||||
"cloningHost": "Cloning host...",
|
||||
"enableTerminal": "Enable Terminal",
|
||||
"enableTerminalDesc": "Enable/disable host visibility in Terminal tab",
|
||||
"enableTunnel": "Enable Tunnel",
|
||||
|
||||
@@ -422,35 +422,6 @@ export function UserEditDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
{showPasswordReset && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
{t("admin.passwordManagement")}
|
||||
</Label>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.warning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.passwordResetWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handlePasswordReset}
|
||||
disabled={passwordResetLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{passwordResetLoading
|
||||
? t("admin.resettingPassword")
|
||||
: t("admin.resetUserPassword")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
|
||||
@@ -43,34 +43,6 @@ export function ConsoleTerminal({
|
||||
const fitAddonRef = React.useRef<FitAddon | null>(null);
|
||||
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getWebSocketBaseUrl = React.useCallback(() => {
|
||||
const isElectronApp = isElectron();
|
||||
|
||||
const isDev =
|
||||
!isElectronApp &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
if (isDev) {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//localhost:30008`;
|
||||
}
|
||||
|
||||
if (isElectronApp) {
|
||||
const baseUrl =
|
||||
(window as { configuredServerUrl?: string }).configuredServerUrl ||
|
||||
"http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/docker/console/`;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/docker/console/`;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!terminal) return;
|
||||
|
||||
@@ -175,7 +147,31 @@ export function ConsoleTerminal({
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
|
||||
const wsUrl = `${getWebSocketBaseUrl()}?token=${encodeURIComponent(token)}`;
|
||||
const isElectronApp = isElectron();
|
||||
|
||||
const isDev =
|
||||
!isElectronApp &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30008`
|
||||
: isElectronApp
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/docker/console/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/docker/console/`;
|
||||
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
@@ -32,16 +32,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip.tsx";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog.tsx";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: DockerContainer;
|
||||
@@ -59,12 +50,12 @@ export function ContainerCard({
|
||||
onRefresh,
|
||||
}: ContainerCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [isStarting, setIsStarting] = React.useState(false);
|
||||
const [isStopping, setIsStopping] = React.useState(false);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
const [isPausing, setIsPausing] = React.useState(false);
|
||||
const [isRemoving, setIsRemoving] = React.useState(false);
|
||||
const [showRemoveDialog, setShowRemoveDialog] = React.useState(false);
|
||||
|
||||
const statusColors = {
|
||||
running: {
|
||||
@@ -191,23 +182,42 @@ export function ContainerCard({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
const force = container.state === "running";
|
||||
await removeDockerContainer(sessionId, container.id, force);
|
||||
toast.success(t("docker.containerRemoved", { name: container.name }));
|
||||
setShowRemoveDialog(false);
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("docker.failedToRemoveContainer", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
const handleRemove = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const containerName = container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name;
|
||||
|
||||
let confirmMessage = t("docker.confirmRemoveContainer", {
|
||||
name: containerName,
|
||||
});
|
||||
|
||||
if (container.state === "running") {
|
||||
confirmMessage += " " + t("docker.runningContainerWarning");
|
||||
}
|
||||
|
||||
confirmWithToast(
|
||||
confirmMessage,
|
||||
async () => {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
const force = container.state === "running";
|
||||
await removeDockerContainer(sessionId, container.id, force);
|
||||
toast.success(t("docker.containerRemoved", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("docker.failedToRemoveContainer", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
},
|
||||
t("common.remove"),
|
||||
t("common.cancel"),
|
||||
);
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
@@ -405,56 +415,18 @@ export function ContainerCard({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-red-400 hover:text-red-300 hover:bg-red-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowRemoveDialog(true);
|
||||
}}
|
||||
onClick={handleRemove}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("docker.remove")}</TooltipContent>{" "}
|
||||
<TooltipContent>{t("docker.remove")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("docker.removeContainer")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("docker.confirmRemoveContainer", {
|
||||
name: container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name,
|
||||
})}
|
||||
{container.state === "running" && (
|
||||
<div className="mt-2 text-yellow-400">
|
||||
{t("docker.runningContainerWarning")}
|
||||
</div>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRemoving}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove();
|
||||
}}
|
||||
disabled={isRemoving}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isRemoving ? t("docker.removing") : t("common.remove")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -329,6 +329,22 @@ export function FileViewer({
|
||||
|
||||
const fileTypeInfo = getFileType(file.name);
|
||||
|
||||
const getImageDataUrl = (content: string, fileName: string): string => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
if (ext === "svg") {
|
||||
try {
|
||||
const base64 = btoa(unescape(encodeURIComponent(content)));
|
||||
return `data:image/svg+xml;base64,${base64}`;
|
||||
} catch (e) {
|
||||
console.error("Failed to encode SVG:", e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return `data:image/*;base64,${content}`;
|
||||
};
|
||||
|
||||
const WARNING_SIZE = 50 * 1024 * 1024;
|
||||
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@@ -353,15 +369,6 @@ export function FileViewer({
|
||||
} else {
|
||||
setShowLargeFileWarning(false);
|
||||
}
|
||||
|
||||
if (
|
||||
fileTypeInfo.type === "image" &&
|
||||
file.name.toLowerCase().endsWith(".svg") &&
|
||||
content
|
||||
) {
|
||||
setImageLoading(false);
|
||||
setImageLoadError(false);
|
||||
}
|
||||
}, [
|
||||
content,
|
||||
savedContent,
|
||||
@@ -716,21 +723,11 @@ export function FileViewer({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : file.name.toLowerCase().endsWith(".svg") ? (
|
||||
<div
|
||||
className="max-w-full max-h-full flex items-center justify-center"
|
||||
style={{ maxHeight: "calc(100vh - 200px)" }}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
onLoad={() => {
|
||||
setImageLoading(false);
|
||||
setImageLoadError(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PhotoProvider maskOpacity={0.7}>
|
||||
<PhotoView src={`data:image/*;base64,${content}`}>
|
||||
<PhotoView src={getImageDataUrl(content, file.name)}>
|
||||
<img
|
||||
src={`data:image/*;base64,${content}`}
|
||||
src={getImageDataUrl(content, file.name)}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
|
||||
style={{ maxHeight: "calc(100vh - 200px)" }}
|
||||
|
||||
@@ -102,6 +102,9 @@ export function ServerStats({
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
||||
const [totpVerified, setTotpVerified] = React.useState(false);
|
||||
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
@@ -137,6 +140,21 @@ export function ServerStats({
|
||||
|
||||
const isActuallyVisible = isVisible && isPageVisible;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!viewerSessionId || !isActuallyVisible) return;
|
||||
|
||||
const heartbeatInterval = setInterval(async () => {
|
||||
try {
|
||||
const { sendMetricsHeartbeat } = await import("@/ui/main-axios.ts");
|
||||
await sendMetricsHeartbeat(viewerSessionId);
|
||||
} catch (error) {
|
||||
console.error("Failed to send heartbeat:", error);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(heartbeatInterval);
|
||||
}, [viewerSessionId, isActuallyVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setServerStatus("offline");
|
||||
@@ -182,6 +200,9 @@ export function ServerStats({
|
||||
setTotpSessionId(null);
|
||||
setShowStatsUI(true);
|
||||
setTotpVerified(true);
|
||||
if (result.viewerSessionId) {
|
||||
setViewerSessionId(result.viewerSessionId);
|
||||
}
|
||||
} else {
|
||||
toast.error(t("serverStats.totpFailed"));
|
||||
}
|
||||
@@ -383,6 +404,10 @@ export function ServerStats({
|
||||
setIsLoadingMetrics(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.viewerSessionId) {
|
||||
setViewerSessionId(result.viewerSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
let retryCount = 0;
|
||||
@@ -453,7 +478,10 @@ export function ServerStats({
|
||||
}
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
await stopMetricsPolling(currentHostConfig.id);
|
||||
await stopMetricsPolling(
|
||||
currentHostConfig.id,
|
||||
viewerSessionId || undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop metrics polling:", error);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
|
||||
color: "#fff",
|
||||
}}
|
||||
formatter={(value: number) => [`${value.toFixed(1)}%`, "Disk"]}
|
||||
cursor={{
|
||||
stroke: "#fb923c",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
@@ -104,6 +109,12 @@ export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
|
||||
strokeWidth={2}
|
||||
fill="url(#diskGradient)"
|
||||
animationDuration={300}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: "#fb923c",
|
||||
stroke: "#fff",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -102,6 +102,11 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||
`${value.toFixed(1)}%`,
|
||||
"Memory",
|
||||
]}
|
||||
cursor={{
|
||||
stroke: "#34d399",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
@@ -110,6 +115,12 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||
strokeWidth={2}
|
||||
fill="url(#memoryGradient)"
|
||||
animationDuration={300}
|
||||
activeDot={{
|
||||
r: 4,
|
||||
fill: "#34d399",
|
||||
stroke: "#fff",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -129,6 +129,7 @@ import { HostTunnelTab } from "./tabs/HostTunnelTab";
|
||||
import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
|
||||
import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
|
||||
import { HostSharingTab } from "./tabs/HostSharingTab";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -168,7 +169,7 @@ export function HostManagerEditor({
|
||||
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
||||
"upload",
|
||||
);
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
@@ -473,6 +474,7 @@ export function HostManagerEditor({
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
name: "",
|
||||
ip: "",
|
||||
@@ -509,19 +511,69 @@ export function HostManagerEditor({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (authTab === "credential") {
|
||||
const currentCredentialId = form.getValues("credentialId");
|
||||
const overrideUsername = form.getValues("overrideCredentialUsername");
|
||||
if (currentCredentialId && !overrideUsername) {
|
||||
const selectedCredential = credentials.find(
|
||||
(c) => c.id === currentCredentialId,
|
||||
);
|
||||
if (selectedCredential) {
|
||||
form.setValue("username", selectedCredential.username);
|
||||
}
|
||||
}
|
||||
const watchedFields = form.watch();
|
||||
const formState = form.formState;
|
||||
|
||||
const isFormValid = React.useMemo(() => {
|
||||
const values = form.getValues();
|
||||
|
||||
if (!values.ip || !values.username) return false;
|
||||
|
||||
if (authTab === "password") {
|
||||
return !!(values.password && values.password.trim() !== "");
|
||||
} else if (authTab === "key") {
|
||||
return !!(values.key && values.keyType);
|
||||
} else if (authTab === "credential") {
|
||||
return !!values.credentialId;
|
||||
} else if (authTab === "none") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [watchedFields, authTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateAuthFields = async () => {
|
||||
form.setValue("authType", authTab, { shouldValidate: true });
|
||||
|
||||
if (authTab === "password") {
|
||||
form.setValue("key", null, { shouldValidate: true });
|
||||
form.setValue("keyPassword", "", { shouldValidate: true });
|
||||
form.setValue("keyType", "auto", { shouldValidate: true });
|
||||
form.setValue("credentialId", null, { shouldValidate: true });
|
||||
} else if (authTab === "key") {
|
||||
form.setValue("password", "", { shouldValidate: true });
|
||||
form.setValue("credentialId", null, { shouldValidate: true });
|
||||
} else if (authTab === "credential") {
|
||||
form.setValue("password", "", { shouldValidate: true });
|
||||
form.setValue("key", null, { shouldValidate: true });
|
||||
form.setValue("keyPassword", "", { shouldValidate: true });
|
||||
form.setValue("keyType", "auto", { shouldValidate: true });
|
||||
|
||||
const currentCredentialId = form.getValues("credentialId");
|
||||
const overrideUsername = form.getValues("overrideCredentialUsername");
|
||||
if (currentCredentialId && !overrideUsername) {
|
||||
const selectedCredential = credentials.find(
|
||||
(c) => c.id === currentCredentialId,
|
||||
);
|
||||
if (selectedCredential) {
|
||||
form.setValue("username", selectedCredential.username, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (authTab === "none") {
|
||||
form.setValue("password", "", { shouldValidate: true });
|
||||
form.setValue("key", null, { shouldValidate: true });
|
||||
form.setValue("keyPassword", "", { shouldValidate: true });
|
||||
form.setValue("keyType", "auto", { shouldValidate: true });
|
||||
form.setValue("credentialId", null, { shouldValidate: true });
|
||||
}
|
||||
|
||||
await form.trigger();
|
||||
};
|
||||
|
||||
updateAuthFields();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authTab, credentials]);
|
||||
|
||||
@@ -690,36 +742,14 @@ export function HostManagerEditor({
|
||||
}, [editingHost]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
await form.trigger();
|
||||
try {
|
||||
isSubmittingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
setFormError(null);
|
||||
|
||||
if (!data.name || data.name.trim() === "") {
|
||||
data.name = `${data.username}@${data.ip}`;
|
||||
}
|
||||
|
||||
if (data.statsConfig) {
|
||||
const statusInterval = data.statsConfig.statusCheckInterval || 30;
|
||||
const metricsInterval = data.statsConfig.metricsInterval || 30;
|
||||
|
||||
if (statusInterval < 5 || statusInterval > 3600) {
|
||||
toast.error(t("hosts.intervalValidation"));
|
||||
setActiveTab("statistics");
|
||||
setFormError(t("hosts.intervalValidation"));
|
||||
isSubmittingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (metricsInterval < 5 || metricsInterval > 3600) {
|
||||
toast.error(t("hosts.intervalValidation"));
|
||||
setActiveTab("statistics");
|
||||
setFormError(t("hosts.intervalValidation"));
|
||||
isSubmittingRef.current = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const submitData: Partial<SSHHost> = {
|
||||
...data,
|
||||
};
|
||||
@@ -799,40 +829,72 @@ export function HostManagerEditor({
|
||||
toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage);
|
||||
console.error("Failed to save host:", error);
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormError = () => {
|
||||
const errors = form.formState.errors;
|
||||
const TAB_PRIORITY = [
|
||||
"general",
|
||||
"terminal",
|
||||
"tunnel",
|
||||
"file_manager",
|
||||
"docker",
|
||||
"statistics",
|
||||
] as const;
|
||||
|
||||
if (
|
||||
errors.ip ||
|
||||
errors.port ||
|
||||
errors.username ||
|
||||
errors.name ||
|
||||
errors.folder ||
|
||||
errors.tags ||
|
||||
errors.pin ||
|
||||
errors.password ||
|
||||
errors.key ||
|
||||
errors.keyPassword ||
|
||||
errors.keyType ||
|
||||
errors.credentialId ||
|
||||
errors.forceKeyboardInteractive ||
|
||||
errors.jumpHosts
|
||||
) {
|
||||
setActiveTab("general");
|
||||
} else if (errors.enableTerminal || errors.terminalConfig) {
|
||||
setActiveTab("terminal");
|
||||
} else if (errors.enableDocker) {
|
||||
setActiveTab("docker");
|
||||
} else if (errors.enableTunnel || errors.tunnelConnections) {
|
||||
setActiveTab("tunnel");
|
||||
} else if (errors.enableFileManager || errors.defaultPath) {
|
||||
setActiveTab("file_manager");
|
||||
} else if (errors.statsConfig) {
|
||||
setActiveTab("statistics");
|
||||
const FIELD_TO_TAB_MAP: Record<string, string> = {
|
||||
ip: "general",
|
||||
port: "general",
|
||||
username: "general",
|
||||
name: "general",
|
||||
folder: "general",
|
||||
tags: "general",
|
||||
pin: "general",
|
||||
password: "general",
|
||||
key: "general",
|
||||
keyPassword: "general",
|
||||
keyType: "general",
|
||||
credentialId: "general",
|
||||
overrideCredentialUsername: "general",
|
||||
forceKeyboardInteractive: "general",
|
||||
jumpHosts: "general",
|
||||
authType: "general",
|
||||
notes: "general",
|
||||
useSocks5: "general",
|
||||
socks5Host: "general",
|
||||
socks5Port: "general",
|
||||
socks5Username: "general",
|
||||
socks5Password: "general",
|
||||
socks5ProxyChain: "general",
|
||||
quickActions: "general",
|
||||
enableTerminal: "terminal",
|
||||
terminalConfig: "terminal",
|
||||
enableDocker: "docker",
|
||||
enableTunnel: "tunnel",
|
||||
tunnelConnections: "tunnel",
|
||||
enableFileManager: "file_manager",
|
||||
defaultPath: "file_manager",
|
||||
statsConfig: "statistics",
|
||||
};
|
||||
|
||||
const handleFormError = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const errors = form.formState.errors;
|
||||
const errorFields = Object.keys(errors);
|
||||
|
||||
if (errorFields.length === 0) return;
|
||||
|
||||
for (const tab of TAB_PRIORITY) {
|
||||
const hasErrorInTab = errorFields.some((field) => {
|
||||
const baseField = field.split(".")[0].split("[")[0];
|
||||
return FIELD_TO_TAB_MAP[baseField] === tab;
|
||||
});
|
||||
|
||||
if (hasErrorInTab) {
|
||||
setActiveTab(tab);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -994,7 +1056,19 @@ export function HostManagerEditor({
|
||||
}, [sshConfigDropdownOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 w-full relative">
|
||||
<SimpleLoader
|
||||
visible={isSubmitting}
|
||||
message={
|
||||
editingHost?.id
|
||||
? t("hosts.updatingHost")
|
||||
: editingHost
|
||||
? t("hosts.cloningHost")
|
||||
: t("hosts.savingHost")
|
||||
}
|
||||
backgroundColor="var(--bg-base)"
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
|
||||
@@ -1130,7 +1204,12 @@ export function HostManagerEditor({
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25" />
|
||||
{!editingHost?.isShared && (
|
||||
<Button className="translate-y-2" type="submit" variant="outline">
|
||||
<Button
|
||||
className="translate-y-2"
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
>
|
||||
{editingHost
|
||||
? editingHost.id
|
||||
? t("hosts.updateHost")
|
||||
|
||||
@@ -1983,6 +1983,7 @@ export async function startMetricsPolling(hostId: number): Promise<{
|
||||
requires_totp?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
viewerSessionId?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post(`/metrics/start/${hostId}`);
|
||||
@@ -1993,20 +1994,64 @@ export async function startMetricsPolling(hostId: number): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopMetricsPolling(hostId: number): Promise<void> {
|
||||
export async function stopMetricsPolling(
|
||||
hostId: number,
|
||||
viewerSessionId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post(`/metrics/stop/${hostId}`);
|
||||
await statsApi.post(`/metrics/stop/${hostId}`, { viewerSessionId });
|
||||
} catch (error) {
|
||||
handleApiError(error, "stop metrics polling");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMetricsHeartbeat(
|
||||
viewerSessionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/metrics/heartbeat", { viewerSessionId });
|
||||
} catch (error) {
|
||||
handleApiError(error, "send metrics heartbeat");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerMetricsViewer(
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean; viewerSessionId: string }> {
|
||||
try {
|
||||
const response = await statsApi.post("/metrics/register-viewer", {
|
||||
hostId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "register metrics viewer");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterMetricsViewer(
|
||||
hostId: number,
|
||||
viewerSessionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/metrics/unregister-viewer", {
|
||||
hostId,
|
||||
viewerSessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
handleApiError(error, "unregister metrics viewer");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitMetricsTOTP(
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post("/metrics/connect-totp", {
|
||||
|
||||
Reference in New Issue
Block a user