From faebdf737461ff9d23a0fc66b2c6258ec0b147c1 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 31 Dec 2025 14:30:54 -0600 Subject: [PATCH] fix: general server stats issues, file manager decoding, ui qol --- src/backend/dashboard.ts | 21 +- src/backend/ssh/file-manager.ts | 4 +- src/backend/ssh/server-stats.ts | 370 ++++++++++++++---- src/locales/en.json | 3 + .../apps/admin/dialogs/UserEditDialog.tsx | 29 -- .../docker/components/ConsoleTerminal.tsx | 54 ++- .../docker/components/ContainerCard.tsx | 106 ++--- .../file-manager/components/FileViewer.tsx | 39 +- .../features/server-stats/ServerStats.tsx | 30 +- .../server-stats/widgets/DiskWidget.tsx | 11 + .../server-stats/widgets/MemoryWidget.tsx | 11 + .../host-manager/hosts/HostManagerEditor.tsx | 215 ++++++---- src/ui/main-axios.ts | 49 ++- 13 files changed, 649 insertions(+), 293 deletions(-) diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index 2186a631..86978dbf 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -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( diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 7d004f82..2769ed8c 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -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 { diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 55f28284..e17f0491 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -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 = {}; const pendingTOTPSessions: Record = {}; @@ -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>(); + private viewerDetails = new Map(); + 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 { 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 { - const refreshedHost = await fetchHostById(host.id, host.userId); + private async pollHostStatus( + host: SSHHostWithCredentials, + viewerUserId?: string, + ): Promise { + 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 { - const refreshedHost = await fetchHostById(host.id, host.userId); + private async pollHostMetrics( + host: SSHHostWithCredentials, + viewerUserId?: string, + ): Promise { + 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 { + 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(); diff --git a/src/locales/en.json b/src/locales/en.json index 3ec6e1f6..b80a8466 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/ui/desktop/apps/admin/dialogs/UserEditDialog.tsx b/src/ui/desktop/apps/admin/dialogs/UserEditDialog.tsx index 3b525f1f..4e155409 100644 --- a/src/ui/desktop/apps/admin/dialogs/UserEditDialog.tsx +++ b/src/ui/desktop/apps/admin/dialogs/UserEditDialog.tsx @@ -422,35 +422,6 @@ export function UserEditDialog({ - {showPasswordReset && ( - <> -
- - - - {t("common.warning")} - - {t("admin.passwordResetWarning")} - - - -
- - - )} -
- - - - - {t("docker.removeContainer")} - - {t("docker.confirmRemoveContainer", { - name: container.name.startsWith("/") - ? container.name.slice(1) - : container.name, - })} - {container.state === "running" && ( -
- {t("docker.runningContainerWarning")} -
- )} -
-
- - - {t("common.cancel")} - - { - e.preventDefault(); - handleRemove(); - }} - disabled={isRemoving} - className="bg-red-600 hover:bg-red-700" - > - {isRemoving ? t("docker.removing") : t("common.remove")} - - -
-
); } diff --git a/src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx b/src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx index f294c83b..d3f8455f 100644 --- a/src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx +++ b/src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx @@ -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({ )} - ) : file.name.toLowerCase().endsWith(".svg") ? ( -
{ - setImageLoading(false); - setImageLoadError(false); - }} - /> ) : ( - + {file.name}(""); const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden); const [totpVerified, setTotpVerified] = React.useState(false); + const [viewerSessionId, setViewerSessionId] = React.useState( + 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); } diff --git a/src/ui/desktop/apps/features/server-stats/widgets/DiskWidget.tsx b/src/ui/desktop/apps/features/server-stats/widgets/DiskWidget.tsx index f929bfe1..78d1fd22 100644 --- a/src/ui/desktop/apps/features/server-stats/widgets/DiskWidget.tsx +++ b/src/ui/desktop/apps/features/server-stats/widgets/DiskWidget.tsx @@ -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", + }} /> diff --git a/src/ui/desktop/apps/features/server-stats/widgets/MemoryWidget.tsx b/src/ui/desktop/apps/features/server-stats/widgets/MemoryWidget.tsx index b9161e62..55d95ea2 100644 --- a/src/ui/desktop/apps/features/server-stats/widgets/MemoryWidget.tsx +++ b/src/ui/desktop/apps/features/server-stats/widgets/MemoryWidget.tsx @@ -102,6 +102,11 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) { `${value.toFixed(1)}%`, "Memory", ]} + cursor={{ + stroke: "#34d399", + strokeWidth: 1, + strokeDasharray: "3 3", + }} /> diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx index 5a694c2b..1f6796ba 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx @@ -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(null); @@ -473,6 +474,7 @@ export function HostManagerEditor({ const form = useForm({ 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 = { ...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 = { + 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 ( -
+
+ +
{!editingHost?.isShared && ( -