diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 04566d27..d23030df 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -21,6 +21,15 @@ services: PORT: "4300" ENABLE_SSL: "true" SSL_DOMAIN: "termix-dev.karmaa.site" + # Resource limits for better large file handling + deploy: + resources: + limits: + memory: 2G + cpus: '1.0' + reservations: + memory: 512M + cpus: '0.5' volumes: termix-dev-data: diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 44aa38fb..02f425e2 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -186,12 +186,23 @@ http { } location /ssh/file_manager/ssh/ { + client_max_body_size 5G; + client_body_timeout 300s; + client_header_timeout 300s; + proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + proxy_request_buffering off; + proxy_buffering off; } location /health { diff --git a/docker/nginx.conf b/docker/nginx.conf index 349e988f..e5abf3cb 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -172,12 +172,23 @@ http { } location /ssh/file_manager/ssh/ { + client_max_body_size 5G; + client_body_timeout 300s; + client_header_timeout 300s; + proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + proxy_request_buffering off; + proxy_buffering off; } location /health { diff --git a/electron/main.cjs b/electron/main.cjs index bf63ef6e..958fba34 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -97,6 +97,163 @@ ipcMain.handle("get-app-version", () => { return app.getVersion(); }); +// GitHub API service for version checking +const GITHUB_API_BASE = "https://api.github.com"; +const REPO_OWNER = "LukeGus"; +const REPO_NAME = "Termix"; + +// Simple cache for GitHub API responses +const githubCache = new Map(); +const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes + +async function fetchGitHubAPI(endpoint, cacheKey) { + // Check cache first + const cached = githubCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + return { + data: cached.data, + cached: true, + cache_age: Date.now() - cached.timestamp, + }; + } + + try { + let fetch; + try { + fetch = globalThis.fetch || require("node-fetch"); + } catch (e) { + const https = require("https"); + const http = require("http"); + const { URL } = require("url"); + + fetch = (url, options = {}) => { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const isHttps = urlObj.protocol === "https:"; + const client = isHttps ? https : http; + + const req = client.request( + url, + { + method: options.method || "GET", + headers: options.headers || {}, + timeout: options.timeout || 10000, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + text: () => Promise.resolve(data), + json: () => Promise.resolve(JSON.parse(data)), + }); + }); + }, + ); + + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("Request timeout")); + }); + + if (options.body) { + req.write(options.body); + } + req.end(); + }); + }; + } + + const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "TermixElectronUpdateChecker/1.0", + "X-GitHub-Api-Version": "2022-11-28", + }, + timeout: 10000, + }); + + if (!response.ok) { + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + + // Cache the response + githubCache.set(cacheKey, { + data, + timestamp: Date.now(), + }); + + return { + data: data, + cached: false, + }; + } catch (error) { + console.error("Failed to fetch from GitHub API:", error); + throw error; + } +} + +// Check for Electron app updates +ipcMain.handle("check-electron-update", async () => { + try { + const localVersion = app.getVersion(); + console.log(`Checking for updates. Local version: ${localVersion}`); + + const releaseData = await fetchGitHubAPI( + `/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, + "latest_release_electron" + ); + + const rawTag = releaseData.data.tag_name || releaseData.data.name || ""; + const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/); + const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; + + if (!remoteVersion) { + console.warn("Remote version not found in GitHub response:", rawTag); + return { + success: false, + error: "Remote version not found", + localVersion, + }; + } + + const isUpToDate = localVersion === remoteVersion; + + const result = { + success: true, + status: isUpToDate ? "up_to_date" : "requires_update", + localVersion: localVersion, + remoteVersion: remoteVersion, + latest_release: { + tag_name: releaseData.data.tag_name, + name: releaseData.data.name, + published_at: releaseData.data.published_at, + html_url: releaseData.data.html_url, + body: releaseData.data.body, + }, + cached: releaseData.cached, + cache_age: releaseData.cache_age, + }; + + console.log(`Version check result: ${result.status}`); + return result; + } catch (error) { + console.error("Version check failed:", error); + return { + success: false, + error: error.message, + localVersion: app.getVersion(), + }; + } +}); + ipcMain.handle("get-platform", () => { return process.platform; }); diff --git a/electron/preload.js b/electron/preload.js index 4c1087fc..dbdce90c 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("electronAPI", { getAppVersion: () => ipcRenderer.invoke("get-app-version"), getPlatform: () => ipcRenderer.invoke("get-platform"), + checkElectronUpdate: () => ipcRenderer.invoke("check-electron-update"), getServerConfig: () => ipcRenderer.invoke("get-server-config"), saveServerConfig: (config) => diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index c1fb0f98..835c7c26 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -860,7 +860,22 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { .json({ error: "File path, name, and content are required" }); } + // Update last active time and extend keepalive for large file operations sshConn.lastActive = Date.now(); + + // For large files, extend the keepalive interval to prevent connection drops + const contentSize = typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.length; + if (contentSize > 10 * 1024 * 1024) { // 10MB threshold + fileLogger.info("Large file upload detected, extending SSH keepalive", { + operation: "file_upload", + sessionId, + fileName, + fileSize: contentSize, + }); + // Extend keepalive interval for large files + // Note: SSH2 client doesn't expose config directly, but we can set keepalive + sshConn.client.setKeepAlive(true, 10000); // 10 seconds + } const fullPath = filePath.endsWith("/") ? filePath + fileName @@ -906,6 +921,13 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { hasError = true; fileLogger.warn( `SFTP write failed, trying fallback method: ${streamErr.message}`, + { + operation: "file_upload", + sessionId, + fileName, + fileSize: contentSize, + error: streamErr.message, + } ); tryFallbackMethod(); }); diff --git a/src/components/ui/version-alert.tsx b/src/components/ui/version-alert.tsx new file mode 100644 index 00000000..0bfae702 --- /dev/null +++ b/src/components/ui/version-alert.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { ExternalLink, Download, X, AlertTriangle } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface VersionAlertProps { + updateInfo: { + success: boolean; + status?: "up_to_date" | "requires_update"; + localVersion?: string; + remoteVersion?: string; + latest_release?: { + tag_name: string; + name: string; + published_at: string; + html_url: string; + body: string; + }; + cached?: boolean; + cache_age?: number; + error?: string; + }; + onDismiss?: () => void; + onDownload?: () => void; + showDismiss?: boolean; +} + +export function VersionAlert({ + updateInfo, + onDismiss, + onDownload, + showDismiss = true, +}: VersionAlertProps) { + const { t } = useTranslation(); + + if (!updateInfo.success) { + return ( + + + {t("versionCheck.error")} + + {updateInfo.error || t("versionCheck.checkFailed")} + + + ); + } + + if (updateInfo.status === "up_to_date") { + return ( + + + {t("versionCheck.upToDate")} + + {t("versionCheck.currentVersion", { version: updateInfo.localVersion })} + + + ); + } + + if (updateInfo.status === "requires_update") { + return ( + + + {t("versionCheck.updateAvailable")} + +
+ {t("versionCheck.newVersionAvailable", { + current: updateInfo.localVersion, + latest: updateInfo.remoteVersion, + })} +
+ + {updateInfo.latest_release && ( +
+
{updateInfo.latest_release.name}
+
+ {t("versionCheck.releasedOn", { + date: new Date(updateInfo.latest_release.published_at).toLocaleDateString(), + })} +
+
+ )} + +
+ {updateInfo.latest_release?.html_url && ( + + )} + + {showDismiss && onDismiss && ( + + )} +
+
+
+ ); + } + + return null; +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 83a8ab8a..e0f941e8 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -202,6 +202,19 @@ "saveConfig": "Save Configuration", "helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)" }, + "versionCheck": { + "error": "Version Check Error", + "checkFailed": "Failed to check for updates", + "upToDate": "App is Up to Date", + "currentVersion": "You are running version {{version}}", + "updateAvailable": "Update Available", + "newVersionAvailable": "A new version is available! You are running {{current}}, but {{latest}} is available.", + "releasedOn": "Released on {{date}}", + "downloadUpdate": "Download Update", + "dismiss": "Dismiss", + "checking": "Checking for updates...", + "checkUpdates": "Check for Updates" + }, "common": { "close": "Close", "minimize": "Minimize", @@ -702,6 +715,7 @@ "uploading": "Uploading...", "downloading": "Downloading...", "uploadingFile": "Uploading {{name}}...", + "uploadingLargeFile": "Uploading large file {{name}} ({{size}})...", "downloadingFile": "Downloading {{name}}...", "creatingFile": "Creating {{name}}...", "creatingFolder": "Creating {{name}}...", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 3e439676..bc09b619 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -200,6 +200,19 @@ "saveConfig": "保存配置", "helpText": "输入您的 Termix 服务器运行地址(例如:http://localhost:30001 或 https://your-server.com)" }, + "versionCheck": { + "error": "版本检查错误", + "checkFailed": "检查更新失败", + "upToDate": "应用已是最新版本", + "currentVersion": "您正在运行版本 {{version}}", + "updateAvailable": "有可用更新", + "newVersionAvailable": "有新版本可用!您正在运行 {{current}},但 {{latest}} 已可用。", + "releasedOn": "发布于 {{date}}", + "downloadUpdate": "下载更新", + "dismiss": "忽略", + "checking": "正在检查更新...", + "checkUpdates": "检查更新" + }, "common": { "close": "关闭", "minimize": "最小化", @@ -724,6 +737,7 @@ "uploading": "上传中...", "downloading": "下载中...", "uploadingFile": "正在上传 {{name}}...", + "uploadingLargeFile": "正在上传大文件 {{name}} ({{size}})...", "downloadingFile": "正在下载 {{name}}...", "creatingFile": "正在创建 {{name}}...", "creatingFolder": "正在创建 {{name}}...", diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx index bc17f069..7d7f9155 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx @@ -62,6 +62,24 @@ interface CreateIntent { currentName: string; } +// Format file size helper function +function formatFileSize(bytes?: number): string { + if (bytes === undefined || bytes === null) return "-"; + if (bytes === 0) return "0 B"; + + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + const formattedSize = size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString(); + return `${formattedSize} ${units[unitIndex]}`; +} + // Internal component, uses window manager function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const { openWindow } = useWindowManager(); @@ -226,13 +244,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const handleFileDragEnd = useCallback( (e: DragEvent, draggedFiles: FileItem[]) => { - // More conservative detection - only trigger download if clearly outside window - const margin = 10; // Very small margin to reduce false positives + // Allow dragging off screen on all sides - only check if completely outside window bounds const isOutside = - e.clientX < margin || - e.clientX > window.innerWidth - margin || - e.clientY < margin || - e.clientY > window.innerHeight - margin; + e.clientX < 0 || + e.clientX > window.innerWidth || + e.clientY < 0 || + e.clientY > window.innerHeight; // Only trigger download if clearly outside the window bounds if (isOutside) { @@ -478,6 +495,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { async function handleUploadFile(file: File) { if (!sshSessionId) return; + // Show progress for large files (>10MB) + const isLargeFile = file.size > 10 * 1024 * 1024; + let progressToast: any = null; + + if (isLargeFile) { + progressToast = toast.loading( + t("fileManager.uploadingLargeFile", { + name: file.name, + size: formatFileSize(file.size) + }), + { duration: Infinity } + ); + } + try { // Ensure SSH connection is valid await ensureSSHConnection(); @@ -493,8 +524,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { file.type === "application/json" || file.type === "application/javascript" || file.type === "application/xml" || + file.type === "image/svg+xml" || file.name.match( - /\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i, + /\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i, ); if (isTextFile) { @@ -532,11 +564,22 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { currentHost?.id, undefined, // userId - will be handled by backend ); + + // Dismiss progress toast if it exists + if (progressToast) { + toast.dismiss(progressToast); + } + toast.success( t("fileManager.fileUploadedSuccessfully", { name: file.name }), ); handleRefreshDirectory(); } catch (error: any) { + // Dismiss progress toast if it exists + if (progressToast) { + toast.dismiss(progressToast); + } + if ( error.message?.includes("connection") || error.message?.includes("established") diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx index 545ec0a4..249ba3e7 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx @@ -1365,8 +1365,8 @@ export function FileManagerGrid({
diff --git a/src/ui/Desktop/Electron Only/ServerConfig.tsx b/src/ui/Desktop/Electron Only/ServerConfig.tsx index 3096ccf1..bbbf1b2c 100644 --- a/src/ui/Desktop/Electron Only/ServerConfig.tsx +++ b/src/ui/Desktop/Electron Only/ServerConfig.tsx @@ -3,14 +3,16 @@ import { Button } from "@/components/ui/button.tsx"; import { Input } from "@/components/ui/input.tsx"; import { Label } from "@/components/ui/label.tsx"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx"; +import { VersionAlert } from "@/components/ui/version-alert.tsx"; import { useTranslation } from "react-i18next"; import { getServerConfig, saveServerConfig, testServerConnection, + checkElectronUpdate, type ServerConfig, } from "@/ui/main-axios.ts"; -import { CheckCircle, XCircle, Server, Wifi } from "lucide-react"; +import { CheckCircle, XCircle, Server, Wifi, RefreshCw } from "lucide-react"; interface ServerConfigProps { onServerConfigured: (serverUrl: string) => void; @@ -31,9 +33,13 @@ export function ServerConfig({ const [connectionStatus, setConnectionStatus] = useState< "unknown" | "success" | "error" >("unknown"); + const [versionInfo, setVersionInfo] = useState(null); + const [versionChecking, setVersionChecking] = useState(false); + const [versionDismissed, setVersionDismissed] = useState(false); useEffect(() => { loadServerConfig(); + checkForUpdates(); }, []); const loadServerConfig = async () => { @@ -46,6 +52,29 @@ export function ServerConfig({ } catch (error) {} }; + const checkForUpdates = async () => { + setVersionChecking(true); + try { + const updateInfo = await checkElectronUpdate(); + setVersionInfo(updateInfo); + } catch (error) { + console.error("Failed to check for updates:", error); + setVersionInfo({ success: false, error: "Check failed" }); + } finally { + setVersionChecking(false); + } + }; + + const handleVersionDismiss = () => { + setVersionDismissed(true); + }; + + const handleDownloadUpdate = () => { + if (versionInfo?.latest_release?.html_url) { + window.open(versionInfo.latest_release.html_url, "_blank"); + } + }; + const handleTestConnection = async () => { if (!serverUrl.trim()) { setError(t("serverConfig.enterServerUrl")); @@ -195,6 +224,34 @@ export function ServerConfig({ )} + {/* Version Check Section */} + {versionInfo && !versionDismissed && ( +
+
+

{t("versionCheck.checkUpdates")}

+ +
+ +
+ )} +
{onCancel && !isFirstTime && (