diff --git a/README.md b/README.md index bba837ea..9ddf04d6 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ If you need help or want to request a feature with Termix, visit the [Issues](ht Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel, however, response times may be longer. -# Show-off +# Screenshots

Termix Demo 1 @@ -145,7 +145,12 @@ channel, however, response times may be longer.

Termix Demo 7 -Termix Demo 8 + Termix Demo 8 +

+ +

+ Termix Demo 9 + Termix Demo 110

diff --git a/repo-images/Image 10.png b/repo-images/Image 10.png new file mode 100644 index 00000000..efc0c012 Binary files /dev/null and b/repo-images/Image 10.png differ diff --git a/repo-images/Image 9.png b/repo-images/Image 9.png new file mode 100644 index 00000000..28d9e70f Binary files /dev/null and b/repo-images/Image 9.png differ diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index 5df48fb6..2186a631 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -127,9 +127,18 @@ app.post("/activity/log", async (req, res) => { }); } - if (type !== "terminal" && type !== "file_manager") { + if ( + ![ + "terminal", + "file_manager", + "server_stats", + "tunnel", + "docker", + ].includes(type) + ) { return res.status(400).json({ - error: "Invalid activity type. Must be 'terminal' or 'file_manager'", + error: + "Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', or 'docker'", }); } diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index e5bff949..55f28284 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -242,11 +242,6 @@ function cleanupMetricsSession(sessionId: string) { } catch (error) {} clearTimeout(session.timeout); delete metricsSessions[sessionId]; - - statsLogger.info("Metrics session cleaned up", { - operation: "session_cleanup", - sessionId, - }); } } @@ -411,13 +406,6 @@ class SSHConnectionPool { totpAttempts: 0, }; - statsLogger.info("TOTP required for metrics collection", { - operation: "metrics_totp_required", - hostId: host.id, - sessionId, - prompt: prompts[totpPromptIndex].prompt, - }); - return; } else if (host.password) { const responses = prompts.map((p) => { @@ -728,7 +716,7 @@ interface AuthFailureRecord { class AuthFailureTracker { private failures = new Map(); private maxRetries = 3; - private backoffBase = 60000; + private backoffBase = 5000; recordFailure( hostId: number, @@ -991,8 +979,12 @@ class PollingManager { return result; } - async startPollingForHost(host: SSHHostWithCredentials): Promise { + async startPollingForHost( + host: SSHHostWithCredentials, + options?: { statusOnly?: boolean }, + ): Promise { const statsConfig = this.parseStatsConfig(host.statsConfig); + const statusOnly = options?.statusOnly ?? false; const existingConfig = this.pollingConfigs.get(host.id); @@ -1034,10 +1026,10 @@ class PollingManager { this.statusStore.delete(host.id); } - if (statsConfig.metricsEnabled) { + if (!statusOnly && statsConfig.metricsEnabled) { const intervalMs = statsConfig.metricsInterval * 1000; - this.pollHostMetrics(host); + await this.pollHostMetrics(host); config.metricsTimer = setInterval(() => { const latestConfig = this.pollingConfigs.get(host.id); @@ -1083,11 +1075,6 @@ class PollingManager { } private async pollHostMetrics(host: SSHHostWithCredentials): Promise { - if (pollingBackoff.shouldSkip(host.id)) { - const backoffInfo = pollingBackoff.getBackoffInfo(host.id); - return; - } - const refreshedHost = await fetchHostById(host.id, host.userId); if (!refreshedHost) { statsLogger.warn("Host not found during metrics polling", { @@ -1102,6 +1089,13 @@ class PollingManager { return; } + const hasExistingMetrics = this.metricsStore.has(refreshedHost.id); + + if (hasExistingMetrics && pollingBackoff.shouldSkip(host.id)) { + const backoffInfo = pollingBackoff.getBackoffInfo(host.id); + return; + } + try { const metrics = await collectMetrics(refreshedHost); this.metricsStore.set(refreshedHost.id, { @@ -1118,7 +1112,7 @@ class PollingManager { const latestConfig = this.pollingConfigs.get(refreshedHost.id); if (latestConfig && latestConfig.statsConfig.metricsEnabled) { const backoffInfo = pollingBackoff.getBackoffInfo(refreshedHost.id); - statsLogger.warn("Failed to collect metrics for host", { + statsLogger.error("Failed to collect metrics for host", { operation: "metrics_poll_failed", hostId: refreshedHost.id, hostName: refreshedHost.name, @@ -1176,7 +1170,7 @@ class PollingManager { const hosts = await fetchAllHosts(userId); for (const host of hosts) { - await this.startPollingForHost(host); + await this.startPollingForHost(host, { statusOnly: true }); } } @@ -1196,7 +1190,7 @@ class PollingManager { } for (const host of hosts) { - await this.startPollingForHost(host); + await this.startPollingForHost(host, { statusOnly: true }); } } @@ -1538,7 +1532,6 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { throw new Error(`Invalid SSH key format for host ${host.ip}`); } } else if (host.authType === "none") { - // Allow "none" auth - SSH will handle via keyboard-interactive } else { throw new Error( `Unsupported authentication type '${host.authType}' for host ${host.ip}`, @@ -1622,8 +1615,11 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ } return requestQueue.queueRequest(host.id, async () => { + const sessionKey = getSessionKey(host.id, host.userId!); + const existingSession = metricsSessions[sessionKey]; + try { - return await withSshConnection(host, async (client) => { + const collectFn = async (client: Client) => { const cpu = await collectCpuMetrics(client); const memory = await collectMemoryMetrics(client); const disk = await collectDiskMetrics(client); @@ -1655,7 +1651,20 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ metricsCache.set(host.id, result); return result; - }); + }; + + if (existingSession && existingSession.isConnected) { + existingSession.activeOperations++; + try { + const result = await collectFn(existingSession.client); + existingSession.lastActive = Date.now(); + return result; + } finally { + existingSession.activeOperations--; + } + } else { + return await withSshConnection(host, collectFn); + } } catch (error) { if (error instanceof Error) { if (error.message.includes("TOTP authentication required")) { @@ -1970,13 +1979,14 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { }); } return; - } else if (host.password) { - const responses = prompts.map((p) => - /password/i.test(p.prompt) ? host.password || "" : "", - ); - finish(responses); } else { - finish(prompts.map(() => "")); + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt) && host.password) { + return host.password; + } + return ""; + }); + finish(responses); } }, ); @@ -2012,11 +2022,44 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { clearTimeout(timeout); if (!isResolved) { isResolved = true; + statsLogger.error("SSH connection error in metrics/start", { + operation: "metrics_start_ssh_error", + hostId: host.id, + error: error instanceof Error ? error.message : String(error), + }); reject(error); } }); - client.connect(config); + if ( + host.useSocks5 && + (host.socks5Host || + (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) + ) { + createSocks5Connection(host.ip, host.port, { + useSocks5: host.useSocks5, + socks5Host: host.socks5Host, + socks5Port: host.socks5Port, + socks5Username: host.socks5Username, + socks5Password: host.socks5Password, + socks5ProxyChain: host.socks5ProxyChain, + }) + .then((socks5Socket) => { + if (socks5Socket) { + config.sock = socks5Socket; + } + client.connect(config); + }) + .catch((error) => { + if (!isResolved) { + isResolved = true; + clearTimeout(timeout); + reject(error); + } + }); + } else { + client.connect(config); + } }); const result = await connectionPromise; @@ -2129,6 +2172,25 @@ app.post("/metrics/connect-totp", async (req, res) => { reject(new Error("TOTP verification timeout")); }, 30000); + session.client.once( + "keyboard-interactive", + (name, instructions, instructionsLang, prompts, finish) => { + statsLogger.warn("Second keyboard-interactive received after TOTP", { + operation: "totp_second_keyboard_interactive", + hostId: session.hostId, + sessionId, + prompts: prompts.map((p) => p.prompt), + }); + const secondResponses = prompts.map((p) => { + if (/password/i.test(p.prompt) && session.resolvedPassword) { + return session.resolvedPassword; + } + return ""; + }); + finish(secondResponses); + }, + ); + session.client.once("ready", () => { clearTimeout(timeout); resolve(); @@ -2136,6 +2198,12 @@ app.post("/metrics/connect-totp", async (req, res) => { session.client.once("error", (error) => { clearTimeout(timeout); + statsLogger.error("SSH client error after TOTP", { + operation: "totp_client_error", + hostId: session.hostId, + sessionId, + error: error instanceof Error ? error.message : String(error), + }); reject(error); }); }); @@ -2159,15 +2227,15 @@ app.post("/metrics/connect-totp", async (req, res) => { const host = await fetchHostById(session.hostId, userId); if (host) { - await pollingManager.startPollingForHost(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), + }); + }); } - statsLogger.info("TOTP verified, metrics collection started", { - operation: "totp_verified", - hostId: session.hostId, - sessionId, - }); - res.json({ success: true }); } catch (error) { statsLogger.error("TOTP verification failed", { diff --git a/src/locales/ar.json b/src/locales/ar.json index 64414ac3..999c5446 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -2395,4 +2395,4 @@ "switchToLight": "قم بتشغيل الضوء", "switchToDark": "التبديل إلى الوضع الداكن" } -} \ No newline at end of file +} diff --git a/src/locales/bn.json b/src/locales/bn.json index 83bd5605..dfb537de 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -2395,4 +2395,4 @@ "switchToLight": "আলোতে স্যুইচ করুন", "switchToDark": "ডার্ক এ স্যুইচ করুন" } -} \ No newline at end of file +} diff --git a/src/locales/cs.json b/src/locales/cs.json index 01d3d314..629c19d0 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -2395,4 +2395,4 @@ "switchToLight": "Přepnout na světlo", "switchToDark": "Přepnout na tmavou" } -} \ No newline at end of file +} diff --git a/src/locales/de.json b/src/locales/de.json index 88299a68..acfb8318 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -2395,4 +2395,4 @@ "switchToLight": "Auf Licht umschalten", "switchToDark": "Auf Dunkel umschalten" } -} \ No newline at end of file +} diff --git a/src/locales/el.json b/src/locales/el.json index c208aa06..2dfc790f 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -2395,4 +2395,4 @@ "switchToLight": "Αλλαγή σε Φως", "switchToDark": "Αλλαγή σε Σκούρο" } -} \ No newline at end of file +} diff --git a/src/locales/en.json b/src/locales/en.json index 2dfcdc4c..3ec6e1f6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1696,6 +1696,7 @@ "totpInvalidCode": "Invalid verification code", "totpCancelled": "Metrics collection cancelled", "authenticationFailed": "Authentication failed", + "noneAuthNotSupported": "Server Stats does not support 'none' authentication type.", "load": "Load", "editLayout": "Edit Layout", "cancelEdit": "Cancel", diff --git a/src/locales/es.json b/src/locales/es.json index 4fc4adaa..b201fb50 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2395,4 +2395,4 @@ "switchToLight": "Cambiar a la luz", "switchToDark": "Cambiar a oscuro" } -} \ No newline at end of file +} diff --git a/src/locales/fr.json b/src/locales/fr.json index 1b84e2a5..bd19263e 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -2395,4 +2395,4 @@ "switchToLight": "Passer à la lumière", "switchToDark": "Passer au mode sombre" } -} \ No newline at end of file +} diff --git a/src/locales/he.json b/src/locales/he.json index 993a3f88..2fdb907e 100644 --- a/src/locales/he.json +++ b/src/locales/he.json @@ -2395,4 +2395,4 @@ "switchToLight": "מעבר לתאורה", "switchToDark": "עבור למצב כהה" } -} \ No newline at end of file +} diff --git a/src/locales/hi.json b/src/locales/hi.json index f61b94d6..12cad703 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -2395,4 +2395,4 @@ "switchToLight": "लाइट पर स्विच करें", "switchToDark": "डार्क मोड पर स्विच करें" } -} \ No newline at end of file +} diff --git a/src/locales/id.json b/src/locales/id.json index 993fabae..16870b66 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -2395,4 +2395,4 @@ "switchToLight": "Beralih ke Cahaya", "switchToDark": "Beralih ke Gelap" } -} \ No newline at end of file +} diff --git a/src/locales/it.json b/src/locales/it.json index b66b0b44..26916589 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -2395,4 +2395,4 @@ "switchToLight": "Passa alla luce", "switchToDark": "Passa a Scuro" } -} \ No newline at end of file +} diff --git a/src/locales/ja.json b/src/locales/ja.json index cfd8d2d3..97e3dfcb 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -2395,4 +2395,4 @@ "switchToLight": "光に切り替える", "switchToDark": "ダークに切り替える" } -} \ No newline at end of file +} diff --git a/src/locales/ko.json b/src/locales/ko.json index b540e1a2..21bc0672 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -2395,4 +2395,4 @@ "switchToLight": "조명으로 전환", "switchToDark": "어둡게 전환" } -} \ No newline at end of file +} diff --git a/src/locales/nb.json b/src/locales/nb.json index 46620277..5aea52c1 100644 --- a/src/locales/nb.json +++ b/src/locales/nb.json @@ -2395,4 +2395,4 @@ "switchToLight": "Bytt til lys", "switchToDark": "Bytt til mørkt" } -} \ No newline at end of file +} diff --git a/src/locales/nl.json b/src/locales/nl.json index e1339db7..34578278 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -2395,4 +2395,4 @@ "switchToLight": "Schakel over naar Licht", "switchToDark": "Schakel over naar Donker" } -} \ No newline at end of file +} diff --git a/src/locales/pl.json b/src/locales/pl.json index d3164c39..8e65ebcd 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -2395,4 +2395,4 @@ "switchToLight": "Przełącz na światło", "switchToDark": "Przełącz na ciemność" } -} \ No newline at end of file +} diff --git a/src/locales/pt.json b/src/locales/pt.json index 31f59486..ac2644bf 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -2395,4 +2395,4 @@ "switchToLight": "Alternar para luz", "switchToDark": "Mudar para o modo escuro" } -} \ No newline at end of file +} diff --git a/src/locales/ro.json b/src/locales/ro.json index fe355555..11dec861 100644 --- a/src/locales/ro.json +++ b/src/locales/ro.json @@ -2395,4 +2395,4 @@ "switchToLight": "Comutare la lumină", "switchToDark": "Comutare la Întuneric" } -} \ No newline at end of file +} diff --git a/src/locales/ru.json b/src/locales/ru.json index a85c965b..b105cedc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2395,4 +2395,4 @@ "switchToLight": "Переключиться на свет", "switchToDark": "Переключиться на темный" } -} \ No newline at end of file +} diff --git a/src/locales/sv.json b/src/locales/sv.json index f75bb091..c3ac2df3 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -2395,4 +2395,4 @@ "switchToLight": "Växla till ljus", "switchToDark": "Växla till mörkt" } -} \ No newline at end of file +} diff --git a/src/locales/th.json b/src/locales/th.json index 4165c102..e9f089d9 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -2395,4 +2395,4 @@ "switchToLight": "เปลี่ยนเป็นโหมดสว่าง", "switchToDark": "เปลี่ยนเป็นโหมดมืด" } -} \ No newline at end of file +} diff --git a/src/locales/tr.json b/src/locales/tr.json index 51ef6584..fc84cdd0 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -2395,4 +2395,4 @@ "switchToLight": "Işığa geç", "switchToDark": "Koyu moda geç" } -} \ No newline at end of file +} diff --git a/src/locales/uk.json b/src/locales/uk.json index a1abac58..77e6cfbc 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -2395,4 +2395,4 @@ "switchToLight": "Перейти на світлий режим", "switchToDark": "Перейти на темний режим" } -} \ No newline at end of file +} diff --git a/src/locales/vi.json b/src/locales/vi.json index 50c0f1a6..a6fecbdd 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -2395,4 +2395,4 @@ "switchToLight": "Chuyển sang chế độ sáng", "switchToDark": "Chuyển sang chế độ Tối" } -} \ No newline at end of file +} diff --git a/src/locales/zh.json b/src/locales/zh.json index 0e04e585..5342be6c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2395,4 +2395,4 @@ "switchToLight": "切换到灯光", "switchToDark": "切换到黑暗模式" } -} \ No newline at end of file +} diff --git a/src/types/index.ts b/src/types/index.ts index bf0ecb1a..786b0694 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -389,7 +389,7 @@ export interface TabContextTab { | "home" | "terminal" | "ssh_manager" - | "server" + | "server_stats" | "admin" | "file_manager" | "user_profile" diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 24451ae8..77d1d7ba 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -180,7 +180,7 @@ function AppContent() { const currentTabData = tabs.find((tab) => tab.id === currentTab); const showTerminalView = currentTabData?.type === "terminal" || - currentTabData?.type === "server" || + currentTabData?.type === "server_stats" || currentTabData?.type === "file_manager" || currentTabData?.type === "tunnel" || currentTabData?.type === "docker"; diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index eb9d9f21..a1a61bf4 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -223,7 +223,7 @@ export function CommandPalette({ const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`; - addTab({ type: "server", title, hostConfig: host }); + addTab({ type: "server_stats", title, hostConfig: host }); setIsOpen(false); }; diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 1932e423..d30f3d79 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -36,6 +36,9 @@ import { Loader2, Terminal, FolderOpen, + Activity, + Container, + ArrowDownUp, } from "lucide-react"; import { Status } from "@/components/ui/shadcn-io/status"; import { BsLightning } from "react-icons/bs"; @@ -297,6 +300,24 @@ export function Dashboard({ title: item.hostName, hostConfig: host, }); + } else if (item.type === "server_stats") { + addTab({ + type: "server_stats", + title: item.hostName, + hostConfig: host, + }); + } else if (item.type === "tunnel") { + addTab({ + type: "tunnel", + title: item.hostName, + hostConfig: host, + }); + } else if (item.type === "docker") { + addTab({ + type: "docker", + title: item.hostName, + hostConfig: host, + }); } }); }; @@ -307,7 +328,7 @@ export function Dashboard({ if (!host) return; addTab({ - type: "server", + type: "server_stats", title: serverName, hostConfig: host, }); @@ -544,7 +565,7 @@ export function Dashboard({

- +

{t("dashboard.totalTunnels")}

@@ -617,8 +638,16 @@ export function Dashboard({ > {item.type === "terminal" ? ( - ) : ( + ) : item.type === "file_manager" ? ( + ) : item.type === "server_stats" ? ( + + ) : item.type === "tunnel" ? ( + + ) : item.type === "docker" ? ( + + ) : ( + )}

{item.hostName} diff --git a/src/ui/desktop/apps/features/docker/DockerManager.tsx b/src/ui/desktop/apps/features/docker/DockerManager.tsx index ade3ef59..eba3b727 100644 --- a/src/ui/desktop/apps/features/docker/DockerManager.tsx +++ b/src/ui/desktop/apps/features/docker/DockerManager.tsx @@ -17,6 +17,7 @@ import { validateDockerAvailability, keepaliveDockerSession, verifyDockerTOTP, + logActivity, } from "@/ui/main-axios.ts"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; import { AlertCircle } from "lucide-react"; @@ -25,6 +26,7 @@ import { ContainerList } from "./components/ContainerList.tsx"; import { ContainerDetail } from "./components/ContainerDetail.tsx"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; +import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; interface DockerManagerProps { hostConfig?: SSHHost; @@ -35,6 +37,12 @@ interface DockerManagerProps { onClose?: () => void; } +interface TabData { + id: number; + type: string; + [key: string]: unknown; +} + export function DockerManager({ hostConfig, title, @@ -45,6 +53,10 @@ export function DockerManager({ }: DockerManagerProps): React.ReactElement { const { t } = useTranslation(); const { state: sidebarState } = useSidebar(); + const { currentTab, removeTab } = useTabs() as { + currentTab: number | null; + removeTab: (tabId: number) => void; + }; const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); const [sessionId, setSessionId] = React.useState(null); const [containers, setContainers] = React.useState([]); @@ -66,6 +78,34 @@ export function DockerManager({ "no_keyboard" | "auth_failed" | "timeout" >("no_keyboard"); + const activityLoggedRef = React.useRef(false); + const activityLoggingRef = React.useRef(false); + + const logDockerActivity = async () => { + if ( + !currentHostConfig?.id || + activityLoggedRef.current || + activityLoggingRef.current + ) { + return; + } + + activityLoggingRef.current = true; + activityLoggedRef.current = true; + + try { + const hostName = + currentHostConfig.name || + `${currentHostConfig.username}@${currentHostConfig.ip}`; + await logActivity("docker", currentHostConfig.id, hostName); + } catch (err) { + console.warn("Failed to log docker activity:", err); + activityLoggedRef.current = false; + } finally { + activityLoggingRef.current = false; + } + }; + React.useEffect(() => { if (hostConfig?.id !== currentHostConfig?.id) { setCurrentHostConfig(hostConfig); @@ -172,6 +212,8 @@ export function DockerManager({ toast.error( validation.error || "Docker is not available on this host", ); + } else { + logDockerActivity(); } } catch (error) { toast.error( @@ -279,6 +321,8 @@ export function DockerManager({ toast.error( validation.error || "Docker is not available on this host", ); + } else { + logDockerActivity(); } } } catch (error) { @@ -294,6 +338,9 @@ export function DockerManager({ setTotpSessionId(null); setTotpPrompt(""); setIsConnecting(false); + if (currentTab !== null) { + removeTab(currentTab); + } }; const handleAuthSubmit = async (credentials: { @@ -345,6 +392,8 @@ export function DockerManager({ if (!validation.available) { toast.error(validation.error || "Docker is not available on this host"); + } else { + logDockerActivity(); } } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to connect"); @@ -422,15 +471,13 @@ export function DockerManager({

-
-
- -

- {isValidating - ? t("docker.validating") - : t("docker.connectingToHost")} -

-
+
+
@@ -490,19 +537,15 @@ export function DockerManager({ -
+
{viewMode === "list" ? (
{sessionId ? ( isLoadingContainers && containers.length === 0 ? ( -
-
- -

- {t("docker.loadingContainers")} -

-
-
+ ) : ( { diff --git a/src/ui/desktop/apps/features/file-manager/FileManagerGrid.tsx b/src/ui/desktop/apps/features/file-manager/FileManagerGrid.tsx index 020c7a7b..c3d56692 100644 --- a/src/ui/desktop/apps/features/file-manager/FileManagerGrid.tsx +++ b/src/ui/desktop/apps/features/file-manager/FileManagerGrid.tsx @@ -1320,7 +1320,7 @@ export function FileManagerGrid({ document.body, )} - +
); } diff --git a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx index 4da26b9b..7aeb7ef3 100644 --- a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx +++ b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx @@ -10,6 +10,7 @@ import { stopMetricsPolling, submitMetricsTOTP, executeSnippet, + logActivity, type ServerMetrics, } from "@/ui/main-axios.ts"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; @@ -76,9 +77,11 @@ export function ServerStats({ }: ServerProps): React.ReactElement { const { t } = useTranslation(); const { state: sidebarState } = useSidebar(); - const { addTab, tabs } = useTabs() as { + const { addTab, tabs, currentTab, removeTab } = useTabs() as { addTab: (tab: { type: string; [key: string]: unknown }) => number; tabs: TabData[]; + currentTab: number | null; + removeTab: (tabId: number) => void; }; const [serverStatus, setServerStatus] = React.useState<"online" | "offline">( "offline", @@ -98,6 +101,10 @@ export function ServerStats({ const [totpSessionId, setTotpSessionId] = React.useState(null); const [totpPrompt, setTotpPrompt] = React.useState(""); const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden); + const [totpVerified, setTotpVerified] = React.useState(false); + + const activityLoggedRef = React.useRef(false); + const activityLoggingRef = React.useRef(false); const statsConfig = React.useMemo((): StatsConfig => { if (!currentHostConfig?.statsConfig) { @@ -140,6 +147,31 @@ export function ServerStats({ setCurrentHostConfig(hostConfig); }, [hostConfig?.id]); + const logServerActivity = async () => { + if ( + !currentHostConfig?.id || + activityLoggedRef.current || + activityLoggingRef.current + ) { + return; + } + + activityLoggingRef.current = true; + activityLoggedRef.current = true; + + try { + const hostName = + currentHostConfig.name || + `${currentHostConfig.username}@${currentHostConfig.ip}`; + await logActivity("server_stats", currentHostConfig.id, hostName); + } catch (err) { + console.warn("Failed to log server stats activity:", err); + activityLoggedRef.current = false; + } finally { + activityLoggingRef.current = false; + } + }; + const handleTOTPSubmit = async (totpCode: string) => { if (!totpSessionId || !currentHostConfig) return; @@ -147,10 +179,11 @@ export function ServerStats({ const result = await submitMetricsTOTP(totpSessionId, totpCode); if (result.success) { setTotpRequired(false); - toast.success(t("serverStats.totpVerified")); - const data = await getServerMetricsById(currentHostConfig.id); - setMetrics(data); + setTotpSessionId(null); setShowStatsUI(true); + setTotpVerified(true); + } else { + toast.error(t("serverStats.totpFailed")); } } catch (error) { toast.error(t("serverStats.totpFailed")); @@ -167,6 +200,9 @@ export function ServerStats({ console.error("Failed to stop metrics polling:", error); } } + if (currentTab !== null) { + removeTab(currentTab); + } }; const renderWidget = (widgetType: WidgetType) => { @@ -301,7 +337,6 @@ export function ServerStats({ React.useEffect(() => { if (!metricsEnabled || !currentHostConfig?.id) { - setShowStatsUI(false); return; } @@ -309,29 +344,77 @@ export function ServerStats({ let pollingIntervalId: number | undefined; let debounceTimeout: NodeJS.Timeout | undefined; + if (isActuallyVisible && !metrics) { + setIsLoadingMetrics(true); + setShowStatsUI(true); + } else if (!isActuallyVisible) { + setIsLoadingMetrics(false); + } + const startMetrics = async () => { if (cancelled) return; - setIsLoadingMetrics(true); + if (currentHostConfig.authType === "none") { + toast.error(t("serverStats.noneAuthNotSupported")); + setIsLoadingMetrics(false); + if (currentTab !== null) { + removeTab(currentTab); + } + return; + } + + const hasExistingMetrics = metrics !== null; + + if (!hasExistingMetrics) { + setIsLoadingMetrics(true); + } + setShowStatsUI(true); try { - const result = await startMetricsPolling(currentHostConfig.id); + if (!totpVerified) { + const result = await startMetricsPolling(currentHostConfig.id); + + if (cancelled) return; + + if (result.requires_totp) { + setTotpRequired(true); + setTotpSessionId(result.sessionId || null); + setTotpPrompt(result.prompt || "Verification code"); + setIsLoadingMetrics(false); + return; + } + } + + let retryCount = 0; + let data = null; + const maxRetries = 15; + const initialDelay = totpVerified ? 3000 : 5000; + const retryDelay = 2000; + + await new Promise((resolve) => setTimeout(resolve, initialDelay)); + + while (retryCount < maxRetries && !cancelled) { + try { + data = await getServerMetricsById(currentHostConfig.id); + break; + } catch (error: any) { + retryCount++; + if (retryCount < maxRetries && !cancelled) { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } else { + throw error; + } + } + } if (cancelled) return; - if (result.requires_totp) { - setTotpRequired(true); - setTotpSessionId(result.sessionId || null); - setTotpPrompt(result.prompt || "Verification code"); - setIsLoadingMetrics(false); - return; - } - - const data = await getServerMetricsById(currentHostConfig.id); - if (!cancelled) { + if (data) { setMetrics(data); - setShowStatsUI(true); - setIsLoadingMetrics(false); + if (!hasExistingMetrics) { + setIsLoadingMetrics(false); + logServerActivity(); + } } pollingIntervalId = window.setInterval(async () => { @@ -355,8 +438,10 @@ export function ServerStats({ if (!cancelled) { console.error("Failed to start metrics polling:", error); setIsLoadingMetrics(false); - setShowStatsUI(false); toast.error(t("serverStats.failedToFetchMetrics")); + if (currentTab !== null) { + removeTab(currentTab); + } } } }; @@ -396,6 +481,7 @@ export function ServerStats({ isActuallyVisible, metricsEnabled, statsConfig.metricsInterval, + totpVerified, ]); const topMarginPx = isTopbarOpen ? 74 : 16; @@ -427,155 +513,162 @@ export function ServerStats({ : "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"; return ( -
+
-
-
-
-

- {currentHostConfig?.folder} / {title} -

-
- {statusCheckEnabled && ( - - - - )} -
-
- - {currentHostConfig?.enableFileManager && ( +
+
- )} + {currentHostConfig?.enableFileManager && ( + + )} - {currentHostConfig?.enableDocker && ( - - )} + {currentHostConfig?.enableDocker && ( + + )} +
-
- + )} + {!totpRequired && } -
+
{(metricsEnabled && showStatsUI) || (currentHostConfig?.quickActions && currentHostConfig.quickActions.length > 0) ? ( -
+
{currentHostConfig?.quickActions && currentHostConfig.quickActions.length > 0 && (
@@ -681,6 +774,7 @@ export function ServerStats({ )} {metricsEnabled && showStatsUI && + !isLoadingMetrics && (!metrics && serverStatus === "offline" ? (
@@ -695,7 +789,7 @@ export function ServerStats({

- ) : ( + ) : metrics ? (
{enabledWidgets.map((widgetType) => (
@@ -703,28 +797,26 @@ export function ServerStats({
))}
- ))} - - {metricsEnabled && showStatsUI && ( - - )} + ) : null)}
) : null} + + {metricsEnabled && ( + + )}
- {totpRequired && ( - - )} +
); } diff --git a/src/ui/desktop/apps/features/tunnel/Tunnel.tsx b/src/ui/desktop/apps/features/tunnel/Tunnel.tsx index bff07a55..32a45ee0 100644 --- a/src/ui/desktop/apps/features/tunnel/Tunnel.tsx +++ b/src/ui/desktop/apps/features/tunnel/Tunnel.tsx @@ -7,6 +7,7 @@ import { connectTunnel, disconnectTunnel, cancelTunnel, + logActivity, } from "@/ui/main-axios.ts"; import type { SSHHost, @@ -27,6 +28,8 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { ); const prevVisibleHostRef = React.useRef(null); + const activityLoggedRef = React.useRef(false); + const activityLoggingRef = React.useRef(false); const haveTunnelConnectionsChanged = ( a: TunnelConnection[] = [], @@ -88,6 +91,25 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { } }, [filterHostKey]); + const logTunnelActivity = async (host: SSHHost) => { + if (!host?.id || activityLoggedRef.current || activityLoggingRef.current) { + return; + } + + activityLoggingRef.current = true; + activityLoggedRef.current = true; + + try { + const hostName = host.name || `${host.username}@${host.ip}`; + await logActivity("tunnel", host.id, hostName); + } catch (err) { + console.warn("Failed to log tunnel activity:", err); + activityLoggedRef.current = false; + } finally { + activityLoggingRef.current = false; + } + }; + const fetchTunnelStatuses = useCallback(async () => { const statusData = await getTunnelStatuses(); setTunnelStatuses(statusData); @@ -120,6 +142,12 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement { return () => clearInterval(interval); }, [fetchTunnelStatuses]); + useEffect(() => { + if (visibleHosts.length > 0 && visibleHosts[0]) { + logTunnelActivity(visibleHosts[0]); + } + }, [visibleHosts.length > 0 ? visibleHosts[0]?.id : null]); + const handleTunnelAction = async ( action: "connect" | "disconnect" | "cancel", host: SSHHost, diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx index 7e46fc25..dff639f9 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx @@ -1537,7 +1537,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { ? host.name : `${host.username}@${host.ip}:${host.port}`; addTab({ - type: "server", + type: "server_stats", title, hostConfig: host, }); diff --git a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx index b0a9cda5..935c3769 100644 --- a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx +++ b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx @@ -246,7 +246,7 @@ export function SSHToolsSidebar({ const splittableTabs = tabs.filter( (tab: TabData) => tab.type === "terminal" || - tab.type === "server" || + tab.type === "server_stats" || tab.type === "file_manager" || tab.type === "tunnel" || tab.type === "docker", diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx index 7fddfad1..40d2bbdc 100644 --- a/src/ui/desktop/navigation/AppView.tsx +++ b/src/ui/desktop/navigation/AppView.tsx @@ -67,7 +67,7 @@ export function AppView({ tabs.filter( (tab: TabData) => tab.type === "terminal" || - tab.type === "server" || + tab.type === "server_stats" || tab.type === "file_manager" || tab.type === "tunnel" || tab.type === "docker", @@ -345,7 +345,7 @@ export function AppView({ splitScreen={allSplitScreenTab.length > 0} onClose={() => removeTab(t.id)} /> - ) : t.type === "server" ? ( + ) : t.type === "server_stats" ? ( - addTab({ type: "server", title, hostConfig: host }) + addTab({ type: "server_stats", title, hostConfig: host }) } className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary" > diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx index a966942d..76109113 100644 --- a/src/ui/desktop/navigation/tabs/Tab.tsx +++ b/src/ui/desktop/navigation/tabs/Tab.tsx @@ -119,13 +119,13 @@ export function Tab({ if ( tabType === "terminal" || - tabType === "server" || + tabType === "server_stats" || tabType === "file_manager" || tabType === "tunnel" || tabType === "docker" || tabType === "user_profile" ) { - const isServer = tabType === "server"; + const isServer = tabType === "server_stats"; const isFileManager = tabType === "file_manager"; const isTunnel = tabType === "tunnel"; const isDocker = tabType === "docker"; diff --git a/src/ui/desktop/navigation/tabs/TabContext.tsx b/src/ui/desktop/navigation/tabs/TabContext.tsx index 4716a5ff..eb71db36 100644 --- a/src/ui/desktop/navigation/tabs/TabContext.tsx +++ b/src/ui/desktop/navigation/tabs/TabContext.tsx @@ -71,7 +71,7 @@ export function TabProvider({ children }: TabProviderProps) { desiredTitle: string | undefined, ): string { const defaultTitle = - tabType === "server" + tabType === "server_stats" ? t("nav.serverStats") : tabType === "file_manager" ? t("nav.fileManager") @@ -139,7 +139,7 @@ export function TabProvider({ children }: TabProviderProps) { const id = nextTabId.current++; const needsUniqueTitle = tabData.type === "terminal" || - tabData.type === "server" || + tabData.type === "server_stats" || tabData.type === "file_manager" || tabData.type === "tunnel" || tabData.type === "docker"; diff --git a/src/ui/desktop/navigation/tabs/TabDropdown.tsx b/src/ui/desktop/navigation/tabs/TabDropdown.tsx index d53fbd28..1d39b739 100644 --- a/src/ui/desktop/navigation/tabs/TabDropdown.tsx +++ b/src/ui/desktop/navigation/tabs/TabDropdown.tsx @@ -31,7 +31,7 @@ export function TabDropdown(): React.ReactElement { return ; case "terminal": return ; - case "server": + case "server_stats": return ; case "file_manager": return ; @@ -54,7 +54,7 @@ export function TabDropdown(): React.ReactElement { switch (tab.type) { case "home": return t("nav.home"); - case "server": + case "server_stats": return tab.title || t("nav.serverStats"); case "file_manager": return tab.title || t("nav.fileManager"); diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 20c0057e..e5430639 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -3085,7 +3085,7 @@ export interface UptimeInfo { export interface RecentActivityItem { id: number; userId: string; - type: "terminal" | "file_manager"; + type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker"; hostId: number; hostName: string; timestamp: string; @@ -3114,7 +3114,7 @@ export async function getRecentActivity( } export async function logActivity( - type: "terminal" | "file_manager", + type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker", hostId: number, hostName: string, ): Promise<{ message: string; id: number | string }> {